Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / codegen / inline / patch_engine.py: 76%

188 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-11-15 07:18 +0800

1# Copyright 2026 Huawei Technologies Co., Ltd 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# ============================================================================ 

15"""Apply inline patch IR to copied modeling source.""" 

16 

17from __future__ import annotations 

18 

19import ast 

20from collections import defaultdict 

21from typing import Iterable 

22 

23from hyper_parallel.codegen.astkit.edits import TextEdit, apply_edits 

24from hyper_parallel.codegen.astkit.index import build_source_index 

25from hyper_parallel.codegen.inline.declaration_edits import declaration_edits 

26from hyper_parallel.codegen.inline.forward_edits import forward_extract_edits 

27from hyper_parallel.codegen.inline.definition_edits import apply_class_removals 

28from hyper_parallel.codegen.inline.ir import ( 

29 ConstructorReplacePatch, 

30 ForwardExtractPatch, 

31 InlinePatchSet, 

32) 

33 

34 

35def apply_patch_set(source_text: str, patch_set: InlinePatchSet) -> str: 

36 """Apply imports, constructor edits, class removals, markers, and forward bodies. 

37 

38 Args: 

39 source_text: Modeling source the patches are applied to. 

40 patch_set: Edits emitted by the inline passes. 

41 

42 Returns: 

43 The patched source text. 

44 """ 

45 

46 text, hits = _apply_source_declarations(source_text, patch_set) 

47 text = _apply_constructor_followups(text, patch_set, hits) 

48 return _apply_forward_patches(text, patch_set.forward_extracts) 

49 

50 

51def _apply_source_declarations( 

52 source_text: str, patch_set: InlinePatchSet 

53) -> tuple[str, dict[str, int]]: 

54 """Apply top-level declarations and the first constructor rewrite wave.""" 

55 index = build_source_index(source_text) 

56 edits = declaration_edits( 

57 source_text, 

58 index.import_end, 

59 index.docstring_end, 

60 patch_set.imports, 

61 patch_set.module_snippets, 

62 ) 

63 waves = _constructor_waves(list(patch_set.constructor_replaces)) 

64 hits: dict[str, int] = defaultdict(int) 

65 if waves: 

66 edits.extend(_constructor_edits(source_text, waves[0], hits)) 

67 text = apply_edits(source_text, edits) if edits else source_text 

68 for wave in waves[1:]: 

69 wave_edits = _constructor_edits(text, wave, hits) 

70 if wave_edits: 

71 text = apply_edits(text, wave_edits) 

72 return text, hits 

73 

74 

75def _apply_constructor_followups( 

76 source_text: str, 

77 patch_set: InlinePatchSet, 

78 hits: dict[str, int], 

79) -> str: 

80 """Apply deferred replacements, validate scopes, and remove replaced classes.""" 

81 deferred_edits = _deferred_replacement_edits( 

82 source_text, 

83 patch_set.constructor_replaces, 

84 hits, 

85 ) 

86 if deferred_edits: 

87 source_text = apply_edits(source_text, deferred_edits) 

88 if patch_set.constructor_replaces: 

89 _require_scoped_rewrites(patch_set.constructor_replaces, hits) 

90 if patch_set.class_removals: 

91 source_text = apply_class_removals(source_text, patch_set.class_removals) 

92 return source_text 

93 

94 

95def _apply_forward_patches( 

96 source_text: str, patches: Iterable[ForwardExtractPatch] 

97) -> str: 

98 """Apply forward extraction only after the source structure has settled.""" 

99 edits = forward_extract_edits(source_text, patches) 

100 return apply_edits(source_text, edits) if edits else source_text 

101 

102 

103def _constructor_waves( 

104 patch_list: list[ConstructorReplacePatch], 

105) -> list[list[ConstructorReplacePatch]]: 

106 """Group constructor rewrites into dependency-ordered waves. 

107 

108 Two compositions force a later wave, re-parsed from the previous 

109 wave's output: a patch whose ``old_ctor`` another remaining patch 

110 introduces (an ``A -> B`` wrap retargeted further by a ``B -> C`` 

111 patch), and a ``name`` retarget sharing its ``old_ctor`` with a 

112 ``wrap_source`` patch — the inline CP subclass retargets the 

113 *outer* wrapper call the wrap introduced, and applying the name 

114 swap first would consume the call site the wrap still needs (a 

115 scoped wrap with no call site fails loudly, so the order is not 

116 optional). 

117 """ 

118 

119 remaining = list(patch_list) 

120 waves: list[list[ConstructorReplacePatch]] = [] 

121 while remaining: 

122 introduced = { 

123 patch.new_ctor for patch in remaining if patch.new_ctor != patch.old_ctor 

124 } 

125 wave = [patch for patch in remaining if _is_wave_ready(patch, remaining, introduced)] 

126 if not wave: 

127 names = sorted({patch.old_ctor for patch in remaining}) 

128 raise ValueError( 

129 f"codegen: constructor replacement dependency cycle among {names}" 

130 ) 

131 chosen = {id(patch) for patch in wave} 

132 remaining = [patch for patch in remaining if id(patch) not in chosen] 

133 waves.append(wave) 

134 return waves 

135 

136 

137def _is_wave_ready( 

138 patch: ConstructorReplacePatch, 

139 remaining: list[ConstructorReplacePatch], 

140 introduced: set[str], 

141) -> bool: 

142 """Return whether a constructor patch has no remaining dependency.""" 

143 if patch.old_ctor in introduced: 

144 return False 

145 if patch.mode != "name": 

146 return True 

147 return not any( 

148 other is not patch 

149 and other.mode == "wrap_source" 

150 and other.old_ctor == patch.old_ctor 

151 for other in remaining 

152 ) 

153 

154 

155def _constructor_edits( 

156 source_text: str, 

157 patches: list[ConstructorReplacePatch], 

158 hits: dict[str, int], 

159) -> list[TextEdit]: 

160 """Build the edits that swap constructor calls per patch target.""" 

161 patches_by_old: dict[str, list[ConstructorReplacePatch]] = defaultdict(list) 

162 for patch in patches: 

163 patches_by_old[patch.old_ctor].append(patch) 

164 if not patches_by_old: 

165 return [] 

166 scopes = _scopes_by_old_ctor(patches) 

167 tree = ast.parse(source_text) 

168 offsets = _line_offsets(source_text) 

169 edits: list[TextEdit] = [] 

170 for node in ast.walk(tree): 

171 if not isinstance(node, ast.Assign): 

172 continue 

173 attr = _assigned_attr(node) 

174 if attr is None: 

175 continue 

176 call = node.value 

177 if not isinstance(call, ast.Call): 

178 continue 

179 name = _call_name(call.func) 

180 if name is None or name not in patches_by_old: 

181 continue 

182 scope = scopes[name] 

183 if scope is not None and attr not in scope: 

184 continue 

185 patch = patches_by_old[name][0] 

186 hits[name] += 1 

187 if patch.mode == "name": 

188 start, end = _node_span(call.func, offsets) 

189 edits.append(TextEdit(start, end, patch.new_ctor)) 

190 elif patch.mode == "wrap_source": 

191 start, end = _node_span(call, offsets) 

192 call_source = source_text[start:end] 

193 extra = "".join(f", {arg}" for arg in patch.keyword_args) 

194 replacement = f"{patch.new_ctor}(module={call_source}{extra})" 

195 edits.append(TextEdit(start, end, replacement)) 

196 else: 

197 raise ValueError(f"unknown constructor replacement mode: {patch.mode!r}") 

198 return _drop_nested_edits(edits) 

199 

200 

201def _scopes_by_old_ctor( 

202 patches: Iterable[ConstructorReplacePatch], 

203) -> dict[str, set[str] | None]: 

204 """Merge each constructor's rewrite scope; ``None`` means unrestricted.""" 

205 

206 scopes: dict[str, set[str] | None] = {} 

207 for patch in patches: 

208 if not patch.scope_attrs: 

209 scopes[patch.old_ctor] = None 

210 continue 

211 merged = scopes.get(patch.old_ctor) 

212 if merged is None and patch.old_ctor in scopes: 

213 continue 

214 if merged is None: 

215 merged = scopes[patch.old_ctor] = set() 

216 merged.update(patch.scope_attrs) 

217 return scopes 

218 

219 

220def _assigned_attr(node: ast.Assign) -> str | None: 

221 """Return the stored attribute of an attribute store, else ``None``. 

222 

223 Module replacements are sunk at the assignment that installs the module, so 

224 scoping by the assigned attribute is exactly the FQN granularity the native 

225 plan used to decide the replacement. Families attach modules at more than 

226 the ``self.<attr>`` store: a loop-local ``layer.<attr>`` upgrade and a 

227 ModuleList element ``self.layers[i].<attr>`` install the same matched 

228 modules, so any attribute store yields its stored attribute -- the rule's 

229 scope set keeps the rewrite at the plan's matched-FQN granularity. 

230 """ 

231 

232 if len(node.targets) != 1: 

233 return None 

234 target = node.targets[0] 

235 return target.attr if isinstance(target, ast.Attribute) else None 

236 

237 

238def _require_scoped_rewrites( 

239 patches: Iterable[ConstructorReplacePatch], 

240 hits: dict[str, int], 

241) -> None: 

242 """Fail when a scoped rule matched FQNs but no source call site. 

243 

244 Silently dropping the rewrite would emit an artifact that no longer matches 

245 the plan that decided the replacement, so a missing call site is a 

246 generation error: the scope handling needs to learn the new shape rather 

247 than the rewrite being widened back to the whole file. 

248 """ 

249 

250 missing = sorted( 

251 {patch.old_ctor for patch in patches if patch.scope_attrs and not hits.get(patch.old_ctor)} 

252 ) 

253 if missing: 

254 raise RuntimeError( 

255 "codegen: no `self.<attr> = <ctor>(...)` call site found for " 

256 f"{missing}; the plan replaced modules of this type, so the " 

257 "replacement cannot be sunk without diverging from the plan" 

258 ) 

259 

260 

261def _deferred_replacement_edits( 

262 source_text: str, 

263 patches: Iterable[ConstructorReplacePatch], 

264 hits: dict[str, int], 

265) -> list[TextEdit]: 

266 """Install observed wrappers after an imported owner built its subtree. 

267 

268 Some source models inherit a decoder whose constructor lives in an 

269 installed dependency. The copied modeling unit therefore has no direct 

270 constructor call to rewrite. For a wrap-style replacement, the native 

271 operation is still expressible exactly: wait until the root model has 

272 built the observed FQNs, then replace those module attributes in place. 

273 """ 

274 pending = [ 

275 patch 

276 for patch in patches 

277 if patch.scope_fqns and not hits.get(patch.old_ctor) 

278 ] 

279 if not pending: 

280 return [] 

281 tree = ast.parse(source_text) 

282 offsets = _line_offsets(source_text) 

283 insertions: dict[int, list[str]] = defaultdict(list) 

284 for patch in pending: 

285 if patch.mode != "wrap_source": 

286 continue 

287 root_attr = patch.scope_fqns[0].split(".", 1)[0] 

288 constructors = _root_constructors(tree, root_attr) 

289 if len(constructors) != 1: 

290 continue 

291 function = constructors[0] 

292 _, end = _node_span(function.body[-1], offsets) 

293 insertions[end].append(_render_deferred_replacement(patch)) 

294 hits[patch.old_ctor] += 1 

295 return [ 

296 TextEdit(offset, offset, "".join(parts)) 

297 for offset, parts in insertions.items() 

298 ] 

299 

300 

301def _root_constructors(tree: ast.Module, root_attr: str) -> list[ast.FunctionDef]: 

302 """Return constructors that establish the first segment of an observed FQN.""" 

303 constructors = [] 

304 for node in tree.body: 

305 if not isinstance(node, ast.ClassDef): 

306 continue 

307 for child in node.body: 

308 if not isinstance(child, ast.FunctionDef) or child.name != "__init__": 

309 continue 

310 if any( 

311 isinstance(target, ast.Attribute) 

312 and isinstance(target.value, ast.Name) 

313 and target.value.id == "self" 

314 and target.attr == root_attr 

315 for item in ast.walk(child) 

316 if isinstance(item, (ast.Assign, ast.AnnAssign)) 

317 for target in ( 

318 item.targets if isinstance(item, ast.Assign) else (item.target,) 

319 ) 

320 ): 

321 constructors.append(child) 

322 return constructors 

323 

324 

325def _render_deferred_replacement(patch: ConstructorReplacePatch) -> str: 

326 """Render an exact-FQN replacement loop inside a root constructor.""" 

327 arguments = [ 

328 argument 

329 for argument in patch.keyword_args 

330 if not argument.startswith("module_fqn=") 

331 ] 

332 extra = "".join(f", {argument}" for argument in arguments) 

333 fqns = repr(tuple(patch.scope_fqns)) 

334 return ( 

335 "\n _hp_replacement_cache = {}\n" 

336 f" for _hp_fqn in {fqns}:\n" 

337 " _hp_parent_fqn, _, _hp_name = _hp_fqn.rpartition('.')\n" 

338 " _hp_parent = self.get_submodule(_hp_parent_fqn)\n" 

339 " _hp_source = getattr(_hp_parent, _hp_name)\n" 

340 " _hp_key = id(_hp_source)\n" 

341 " if _hp_key not in _hp_replacement_cache:\n" 

342 f" _hp_replacement_cache[_hp_key] = {patch.new_ctor}(\n" 

343 f" module=_hp_source, module_fqn=_hp_fqn{extra})\n" 

344 " setattr(_hp_parent, _hp_name, _hp_replacement_cache[_hp_key])" 

345 ) 

346 

347 

348def _drop_nested_edits(edits: list[TextEdit]) -> list[TextEdit]: 

349 """Keep outer constructor rewrites when constructor calls are nested.""" 

350 kept: list[TextEdit] = [] 

351 covered_end = -1 

352 for edit in sorted(edits, key=lambda item: (item.start, -item.end)): 

353 if edit.start < covered_end: 

354 continue 

355 kept.append(edit) 

356 covered_end = edit.end 

357 return kept 

358 

359 

360def _call_name(node: ast.AST) -> str | None: 

361 if isinstance(node, ast.Name): 

362 return node.id 

363 if isinstance(node, ast.Attribute): 

364 return node.attr 

365 return None 

366 

367 

368def _node_span(node: ast.AST, offsets: tuple[int, ...]) -> tuple[int, int]: 

369 """Return the ``(start, end)`` source span of ``node``. 

370 

371 Decorators are part of the span: deleting only the ``def``/``class`` line 

372 would leave a dangling decorator that then applies to whatever statement 

373 follows the removed block. 

374 """ 

375 

376 if ( 

377 getattr(node, "lineno", None) is None 

378 or getattr(node, "col_offset", None) is None 

379 or getattr(node, "end_lineno", None) is None 

380 or getattr(node, "end_col_offset", None) is None 

381 ): 

382 raise ValueError(f"AST node has no source span: {type(node).__name__}") 

383 line = node.lineno 

384 column = node.col_offset 

385 decorators = getattr(node, "decorator_list", None) 

386 if decorators: 

387 first = min(decorators, key=lambda item: item.lineno) 

388 if first.lineno < line: 

389 # A decorator node starts at its expression, one column *after* the 

390 # ``@``; the span must start at the ``@`` itself, otherwise deleting 

391 # the block leaves a stray ``@`` behind. 

392 line, column = first.lineno, max(0, first.col_offset - 1) 

393 start = offsets[line - 1] + column 

394 end = offsets[node.end_lineno - 1] + node.end_col_offset 

395 return start, end 

396 

397 

398def _line_offsets(source_text: str) -> tuple[int, ...]: 

399 offsets = [0] 

400 total = 0 

401 for line in source_text.splitlines(keepends=True): 

402 total += len(line) 

403 offsets.append(total) 

404 return tuple(offsets)