Coverage for sygaldry / codegen.py: 84%

369 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 18:36 -0400

1from __future__ import annotations 

2 

3__author__ = "Rohan B. Dalton" 

4 

5import importlib 

6import inspect 

7import re 

8from dataclasses import dataclass, field 

9from typing import Any, Optional 

10 

11from .types import RESERVED_KEYS 

12 

13_NON_IDENTIFIER_RE = re.compile(r"[^a-zA-Z0-9_]") 

14 

15 

16@dataclass(frozen=True) 

17class SourceMapping: 

18 """ 

19 Map a generated line number to a config path. 

20 """ 

21 

22 line: int 

23 config_path: str 

24 

25 

26@dataclass 

27class ComponentEntry: 

28 """ 

29 A typed component extracted from the config. 

30 """ 

31 

32 config_path: str 

33 var_name: str 

34 import_path: str 

35 args: list[Any] = field(default_factory=list) 

36 kwargs: dict[str, Any] = field(default_factory=dict) 

37 is_func: bool = False 

38 

39 

40@dataclass 

41class PlainEntry: 

42 """ 

43 A plain (non-typed) config value. 

44 """ 

45 

46 config_path: str 

47 var_name: str 

48 value: Any 

49 

50 

51@dataclass 

52class RefExpression: 

53 """ 

54 A reference expression to be emitted as a variable or attribute access. 

55 """ 

56 

57 target: str 

58 

59 

60@dataclass 

61class EntriesExpression: 

62 """ 

63 A dict built from _entries. 

64 """ 

65 

66 items: list[tuple[Any, Any]] 

67 

68 

69@dataclass 

70class AnalysisResult: 

71 """ 

72 Result of analyzing a config dict. 

73 """ 

74 

75 components: list[ComponentEntry] = field(default_factory=list) 

76 plains: list[PlainEntry] = field(default_factory=list) 

77 topological_order: list[str] = field(default_factory=list) 

78 imports: dict[str, tuple[str, str]] = field(default_factory=dict) 

79 

80 

81def _to_identifier(key: str) -> str: 

82 """ 

83 Convert a config key to a valid Python identifier. 

84 """ 

85 if result := _NON_IDENTIFIER_RE.sub("_", key): 

86 if result[0].isdigit(): 

87 result = f"_{result}" 

88 return result 

89 else: 

90 return "_unnamed" 

91 

92 

93def _split_import_path(dotted: str) -> tuple[str, str]: 

94 """ 

95 Split 'a.b.ClassName' into ('a.b', 'ClassName'). 

96 """ 

97 parts = dotted.rsplit(".", 1) 

98 if len(parts) == 1: 

99 return parts[0], parts[0] 

100 else: 

101 return parts[0], parts[1] 

102 

103 

104class ConfigAnalyzer: 

105 """ 

106 Walk a loaded config dict and extract typed component specs. 

107 """ 

108 

109 def __init__(self, config: dict[str, Any]) -> None: 

110 self._config = config 

111 self._components: list[ComponentEntry] = list() 

112 self._plains: list[PlainEntry] = list() 

113 self._imports: dict[str, tuple[str, str]] = dict() 

114 self._deps: dict[str, list[str]] = dict() 

115 self._variable_names: dict[str, str] = dict() 

116 self._anon_counter = 0 

117 self._used_names: set[str] = set() 

118 

119 def analyze(self) -> AnalysisResult: 

120 """ 

121 Analyze the config and return structured results. 

122 """ 

123 for key, value in self._config.items(): 

124 self._analyze_top_level(key, value) 

125 order = self._topological_sort() 

126 result = AnalysisResult( 

127 components=self._components, 

128 plains=self._plains, 

129 topological_order=order, 

130 imports=self._imports, 

131 ) 

132 return result 

133 

134 def _alloc_name(self, preferred: str) -> str: 

135 """ 

136 Allocate a unique variable name. 

137 """ 

138 name = _to_identifier(preferred) 

139 if name not in self._used_names: 139 ↛ 143line 139 didn't jump to line 143 because the condition on line 139 was always true

140 self._used_names.add(name) 

141 return name 

142 else: 

143 counter = 0 

144 while f"{name}_{counter}" in self._used_names: 

145 counter += 1 

146 unique = f"{name}_{counter}" 

147 self._used_names.add(unique) 

148 return unique 

149 

150 def _alloc_anon(self, hint: str) -> str: 

151 """ 

152 Allocate an anonymous variable name. 

153 """ 

154 name = f"__sygaldry_{_to_identifier(hint)}_{self._anon_counter}" 

155 self._anon_counter += 1 

156 self._used_names.add(name) 

157 return name 

158 

159 def _analyze_top_level(self, key: str, value: Any) -> None: 

160 """ 

161 Analyze a single top-level config entry. 

162 """ 

163 variable_name = self._alloc_name(key) 

164 self._variable_names[key] = variable_name 

165 self._deps[key] = list() 

166 

167 if isinstance(value, dict): 

168 if "_ref" in value: 

169 ref_target = value["_ref"] 

170 top = ref_target.split(".")[0] 

171 self._deps[key].append(top) 

172 self._plains.append( 

173 PlainEntry( 

174 config_path=key, 

175 var_name=variable_name, 

176 value=RefExpression(target=ref_target), 

177 ) 

178 ) 

179 elif "_type" in value: 

180 self._analyze_type_entry(key, variable_name, value) 

181 elif "_func" in value: 

182 self._analyze_func_entry(key, variable_name, value) 

183 elif value.get("_deep") is False: 

184 self._plains.append( 

185 PlainEntry(config_path=key, var_name=variable_name, value=...) 

186 ) 

187 elif "_entries" in value: 

188 entries_expr = self._analyze_entries(value["_entries"], key, self._deps[key]) 

189 self._plains.append( 

190 PlainEntry(config_path=key, var_name=variable_name, value=entries_expr) 

191 ) 

192 else: 

193 self._plains.append( 

194 PlainEntry(config_path=key, var_name=variable_name, value=value) 

195 ) 

196 else: 

197 self._plains.append( 

198 PlainEntry(config_path=key, var_name=variable_name, value=value) 

199 ) 

200 

201 def _analyze_type_entry( 

202 self, config_path: str, var_name: str, entry: dict[str, Any] 

203 ) -> None: 

204 """ 

205 Analyze a _type entry. 

206 """ 

207 import_path = entry["_type"] 

208 mod, name = _split_import_path(import_path) 

209 self._imports[import_path] = (mod, name) 

210 

211 raw_args = entry.get("_args", []) 

212 args = list() 

213 for idx, arg in enumerate(raw_args): 

214 processed = self._process_arg_value( 

215 arg, f"{config_path}._args.{idx}", self._deps.setdefault(config_path, []) 

216 ) 

217 args.append(processed) 

218 

219 kwargs: dict[str, Any] = dict() 

220 for key, value in entry.items(): 

221 if key in RESERVED_KEYS: 

222 continue 

223 processed = self._process_arg_value( 

224 value, f"{config_path}.{key}", self._deps.setdefault(config_path, []) 

225 ) 

226 kwargs[key] = processed 

227 

228 raw_kwargs = entry.get("_kwargs", {}) 

229 for key, value in raw_kwargs.items(): 

230 processed = self._process_arg_value( 

231 value, f"{config_path}._kwargs.{key}", self._deps.setdefault(config_path, []) 

232 ) 

233 kwargs[key] = processed 

234 

235 self._components.append( 

236 ComponentEntry( 

237 config_path=config_path, 

238 var_name=var_name, 

239 import_path=import_path, 

240 args=args, 

241 kwargs=kwargs, 

242 ) 

243 ) 

244 

245 def _analyze_func_entry( 

246 self, config_path: str, var_name: str, value: dict[str, Any] 

247 ) -> None: 

248 """ 

249 Analyze a _func entry. 

250 """ 

251 import_path = value["_func"] 

252 module, name = _split_import_path(import_path) 

253 self._imports[import_path] = (module, name) 

254 component = ComponentEntry( 

255 config_path=config_path, 

256 var_name=var_name, 

257 import_path=import_path, 

258 is_func=True, 

259 ) 

260 self._components.append(component) 

261 

262 def _process_arg_value(self, value: Any, config_path: str, deps: list[str]) -> Any: 

263 """ 

264 Process a value that appears as an argument to a _type constructor. 

265 

266 Returns the value as-is for scalars, as a RefExpression for _ref, 

267 or hoists nested _type entries to intermediate variables. 

268 """ 

269 if not isinstance(value, dict): 

270 if isinstance(value, list): 

271 return [ 

272 self._process_arg_value(item, f"{config_path}.{idx}", deps) 

273 for idx, item in enumerate(value) 

274 ] 

275 return value 

276 

277 if "_ref" in value: 

278 ref_target = value["_ref"] 

279 top = ref_target.split(".")[0] 

280 deps.append(top) 

281 return RefExpression(target=ref_target) 

282 

283 elif "_type" in value: 283 ↛ 288line 283 didn't jump to line 288 because the condition on line 283 was always true

284 anon_name = self._alloc_anon(config_path.replace(".", "_")) 

285 self._analyze_type_entry(config_path, anon_name, value) 

286 return RefExpression(target=anon_name) 

287 

288 elif "_func" in value: 

289 anon_name = self._alloc_anon(config_path.replace(".", "_")) 

290 self._analyze_func_entry(config_path, anon_name, value) 

291 return RefExpression(target=anon_name) 

292 

293 elif value.get("_deep") is False: 

294 return ... 

295 

296 elif "_entries" in value: 

297 return self._analyze_entries(value["_entries"], config_path, deps) 

298 

299 else: 

300 return value 

301 

302 def _analyze_entries( 

303 self, entries: Any, config_path: str, deps: list[str] 

304 ) -> EntriesExpression: 

305 """ 

306 Analyze an _entries list. 

307 """ 

308 items: list[tuple[Any, Any]] = list() 

309 if not isinstance(entries, list): 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 return EntriesExpression(items=[]) 

311 for idx, entry in enumerate(entries): 

312 if not isinstance(entry, dict): 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true

313 continue 

314 key = self._process_arg_value(entry.get("_key"), f"{config_path}.{idx}._key", deps) 

315 value = self._process_arg_value( 

316 entry.get("_value"), f"{config_path}.{idx}._value", deps 

317 ) 

318 item = (key, value) 

319 items.append(item) 

320 return EntriesExpression(items=items) 

321 

322 def _topological_sort(self) -> list[str]: 

323 """ 

324 Sort entries so that dependencies come before dependents. 

325 """ 

326 visited: set[str] = set() 

327 order: list[str] = list() 

328 visiting: set[str] = set() 

329 

330 keys = {component.config_path for component in self._components} 

331 keys.update({plain.config_path for plain in self._plains}) 

332 

333 def visit(key_: str) -> None: 

334 # What the absolute fuck?:w 

335 if key_ in visited or key_ in visiting: 

336 return 

337 

338 visiting.add(key_) 

339 for dep in self._deps.get(key_, []): 

340 if dep in keys: 340 ↛ 339line 340 didn't jump to line 339 because the condition on line 340 was always true

341 visit(dep) 

342 visiting.discard(key_) 

343 visited.add(key_) 

344 order.append(key_) 

345 

346 for key in keys: 

347 visit(key) 

348 

349 return order 

350 

351 

352class CodeGenerator: 

353 """ 

354 Generate Python source code from analysis results. 

355 """ 

356 

357 def __init__(self, analysis: AnalysisResult, *, line_length: int = 99) -> None: 

358 self._analysis = analysis 

359 self._line_length = line_length 

360 self._lines: list[str] = list() 

361 self._mappings: list[SourceMapping] = list() 

362 self._entry_map: dict[str, Any] = dict() 

363 

364 for component in analysis.components: 

365 self._entry_map[component.config_path] = component 

366 for plain in analysis.plains: 

367 self._entry_map[plain.config_path] = plain 

368 for component in analysis.components: 

369 self._entry_map[component.var_name] = component 

370 for plain in analysis.plains: 

371 self._entry_map[plain.var_name] = plain 

372 

373 def generate( 

374 self, 

375 call_target: str | None = None, 

376 call_method: str | None = None, 

377 call_args: list[Any] | None = None, 

378 ) -> tuple[str, list[SourceMapping]]: 

379 """ 

380 Generate the full source code. 

381 """ 

382 needs_asyncio = call_target is not None and self._is_async_call( 

383 call_target, call_method 

384 ) 

385 self._emit_header(needs_asyncio=needs_asyncio) 

386 self._emit_imports() 

387 self._emit_blank() 

388 self._emit_entries() 

389 if call_target is not None: 

390 self._emit_blank() 

391 self._emit_call(call_target, call_method, call_args or []) 

392 return "\n".join(self._lines) + "\n", list(self._mappings) 

393 

394 def _emit(self, line: str, config_path: str | None = None) -> None: 

395 """ 

396 Emit a line of code, optionally recording its config path. 

397 """ 

398 self._lines.append(line) 

399 if config_path is not None: 

400 mapping = SourceMapping(line=len(self._lines), config_path=config_path) 

401 self._mappings.append(mapping) 

402 

403 def _emit_blank(self) -> None: 

404 self._lines.append("") 

405 

406 def _emit_header(self, *, needs_asyncio: bool = False) -> None: 

407 self._emit("# Auto-generated by sygaldry for type checking — do not edit") 

408 if needs_asyncio: 

409 self._emit("import asyncio") 

410 self._emit("from typing import Any") 

411 

412 def _emit_imports(self) -> None: 

413 seen: set[str] = set() 

414 for _, (mod, name) in sorted(self._analysis.imports.items(), key=lambda x: x[0]): 

415 statement = f"from {mod} import {name}" 

416 if statement not in seen: 416 ↛ 414line 416 didn't jump to line 414 because the condition on line 416 was always true

417 seen.add(statement) 

418 self._emit(statement) 

419 

420 def _emit_entries(self) -> None: 

421 """ 

422 Emit all entries in topological order. 

423 """ 

424 emitted: set[str] = set() 

425 for config_path in self._analysis.topological_order: 

426 if config_path in emitted: 426 ↛ 427line 426 didn't jump to line 427 because the condition on line 426 was never true

427 continue 

428 entry = self._entry_map.get(config_path) 

429 if entry is None: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

430 continue 

431 

432 emitted.add(config_path) 

433 

434 if isinstance(entry, ComponentEntry): 

435 self._emit_component(entry) 

436 elif isinstance(entry, PlainEntry): 436 ↛ 425line 436 didn't jump to line 425 because the condition on line 436 was always true

437 self._emit_plain(entry) 

438 

439 def _is_async_call(self, target: str, method: str | None) -> bool: 

440 """ 

441 Check whether the call target's method (or __call__) is async. 

442 """ 

443 entry = self._entry_map.get(target) 

444 if not isinstance(entry, ComponentEntry): 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 return False 

446 try: 

447 mod_path, cls_name = _split_import_path(entry.import_path) 

448 mod = importlib.import_module(mod_path) 

449 cls = getattr(mod, cls_name) 

450 if method: 

451 func = getattr(cls, method, None) 

452 else: 

453 func = getattr(cls, "__call__", None) 

454 return inspect.iscoroutinefunction(func) 

455 except Exception: 

456 return False 

457 

458 def _emit_call( 

459 self, 

460 target: str, 

461 method: str | None, 

462 args: list[Any], 

463 ) -> None: 

464 """ 

465 Emit a call expression at the end of the generated code. 

466 """ 

467 var_name = _to_identifier(target) 

468 if entry := self._entry_map.get(target): 468 ↛ 472line 468 didn't jump to line 472 because the condition on line 468 was always true

469 if isinstance(entry, (ComponentEntry, PlainEntry)): 469 ↛ 472line 469 didn't jump to line 472 because the condition on line 469 was always true

470 var_name = entry.var_name 

471 

472 is_async = self._is_async_call(target, method) 

473 

474 callee = f"{var_name}.{method}" if method else var_name 

475 arg_parts = [self._value_to_source(arg) for arg in args] 

476 args_str = ", ".join(arg_parts) 

477 call_expr = f"{callee}({args_str})" 

478 

479 if is_async: 

480 one_line = f"asyncio.run({call_expr})" 

481 if len(one_line) <= self._line_length or not arg_parts: 481 ↛ 484line 481 didn't jump to line 484 because the condition on line 481 was always true

482 self._emit(one_line) 

483 else: 

484 self._emit(f"asyncio.run({callee}(") 

485 for idx, arg_part in enumerate(arg_parts): 

486 suffix = "," if idx < len(arg_parts) - 1 else "" 

487 self._emit(f" {arg_part}{suffix}") 

488 self._emit("))") 

489 elif len(call_expr) <= self._line_length or not arg_parts: 489 ↛ 492line 489 didn't jump to line 492 because the condition on line 489 was always true

490 self._emit(call_expr) 

491 else: 

492 self._emit(f"{callee}(") 

493 for idx, arg_part in enumerate(arg_parts): 

494 suffix = "," if idx < len(arg_parts) - 1 else "" 

495 self._emit(f" {arg_part}{suffix}") 

496 self._emit(")") 

497 

498 def _emit_component(self, comp: ComponentEntry) -> None: 

499 """ 

500 Emit a component (class instantiation or func import). 

501 """ 

502 _, name = _split_import_path(comp.import_path) 

503 if comp.is_func: 

504 self._emit( 

505 f"{comp.var_name} = {name} # config: {comp.config_path}", 

506 config_path=comp.config_path, 

507 ) 

508 return 

509 

510 parts = [self._value_to_source(arg) for arg in comp.args] 

511 for key, value in comp.kwargs.items(): 

512 parts.append(f"{key}={self._value_to_source(value)}") 

513 

514 comment = f" # config: {comp.config_path}" 

515 args_str = ", ".join(parts) 

516 one_line = f"{comp.var_name}: {name} = {name}({args_str}){comment}" 

517 if len(one_line) <= self._line_length or not parts: 517 ↛ 520line 517 didn't jump to line 520 because the condition on line 517 was always true

518 self._emit(one_line, config_path=comp.config_path) 

519 else: 

520 self._emit( 

521 f"{comp.var_name}: {name} = {name}({comment}", config_path=comp.config_path 

522 ) 

523 for part in parts: 

524 self._emit(f" {part},") 

525 self._emit(")") 

526 

527 def _emit_plain(self, plain: PlainEntry) -> None: 

528 """ 

529 Emit a plain value assignment. 

530 """ 

531 comment = f" # config: {plain.config_path}" 

532 if isinstance(plain.value, RefExpression): 

533 target = self._resolve_ref_source(plain.value.target) 

534 self._emit( 

535 f"{plain.var_name} = {target}{comment}", 

536 config_path=plain.config_path, 

537 ) 

538 elif plain.value is ...: 

539 self._emit( 

540 f"{plain.var_name}: Any = ...{comment}", 

541 config_path=plain.config_path, 

542 ) 

543 elif isinstance(plain.value, EntriesExpression): 

544 items = [ 

545 (self._value_to_source(key), self._value_to_source(value)) 

546 for key, value in plain.value.items 

547 ] 

548 items_str = ", ".join(f"{k}: {v}" for k, v in items) 

549 one_line = f"{plain.var_name}: dict = {{{items_str}}}{comment}" 

550 if len(one_line) <= self._line_length or not items: 550 ↛ 553line 550 didn't jump to line 553 because the condition on line 550 was always true

551 self._emit(one_line, config_path=plain.config_path) 

552 else: 

553 self._emit( 

554 f"{plain.var_name}: dict = {{{comment}", config_path=plain.config_path 

555 ) 

556 for i, (k, v) in enumerate(items): 

557 suffix = "," 

558 self._emit(f" {k}: {v}{suffix}") 

559 self._emit("}") 

560 else: 

561 type_name = self._python_type_name(plain.value) 

562 self._emit( 

563 f"{plain.var_name}: {type_name} = {self._value_to_source(plain.value)}{comment}", 

564 config_path=plain.config_path, 

565 ) 

566 

567 def _resolve_ref_source(self, target: str) -> str: 

568 """ 

569 Convert a ref target to a Python expression. 

570 """ 

571 top, _, remainder = target.partition(".") 

572 if entry := self._entry_map.get(top): 572 ↛ 578line 572 didn't jump to line 578 because the condition on line 572 was always true

573 if isinstance(entry, (ComponentEntry, PlainEntry)): 573 ↛ 576line 573 didn't jump to line 576 because the condition on line 573 was always true

574 var_name = entry.var_name 

575 else: 

576 var_name = _to_identifier(top) 

577 else: 

578 var_name = _to_identifier(top) 

579 if remainder: 

580 return f"{var_name}.{remainder}" 

581 return var_name 

582 

583 def _value_to_source(self, value: Any) -> str: 

584 """ 

585 Convert a config value to its Python source representation. 

586 """ 

587 if isinstance(value, RefExpression): 

588 return self._resolve_ref_source(value.target) 

589 elif isinstance(value, EntriesExpression): 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true

590 items_str = ", ".join( 

591 f"{self._value_to_source(k)}: {self._value_to_source(v)}" 

592 for k, v in value.items 

593 ) 

594 return f"{{{items_str}}}" 

595 elif value is ...: 595 ↛ 596line 595 didn't jump to line 596 because the condition on line 595 was never true

596 return "..." 

597 elif value is None: 

598 return "None" 

599 elif isinstance(value, bool): 

600 return "True" if value else "False" 

601 elif isinstance(value, (float, int, str)): 

602 return repr(value) 

603 elif isinstance(value, list): 

604 items = ", ".join(self._value_to_source(v) for v in value) 

605 return f"[{items}]" 

606 elif isinstance(value, dict): 606 ↛ 613line 606 didn't jump to line 613 because the condition on line 606 was always true

607 items = ", ".join( 

608 f"{self._value_to_source(k)}: {self._value_to_source(v)}" 

609 for k, v in value.items() 

610 ) 

611 return f"{{{items}}}" 

612 else: 

613 return repr(value) 

614 

615 @staticmethod 

616 def _python_type_name(value: Any) -> str: 

617 """ 

618 Get a type annotation name for a plain value. 

619 """ 

620 if value is None: 

621 return "None" 

622 elif isinstance(value, bool): 

623 return "bool" 

624 elif isinstance(value, int): 

625 return "int" 

626 elif isinstance(value, float): 

627 return "float" 

628 elif isinstance(value, str): 

629 return "str" 

630 elif isinstance(value, list): 

631 return "list" 

632 elif isinstance(value, dict): 632 ↛ 635line 632 didn't jump to line 635 because the condition on line 632 was always true

633 return "dict" 

634 else: 

635 return "Any" 

636 

637 

638def generate_check_source( 

639 config: dict[str, Any], 

640 *, 

641 line_length: int = 99, 

642 call_target: str | None = None, 

643 call_method: str | None = None, 

644 call_args: list[Any] | None = None, 

645) -> tuple[str, list[SourceMapping]]: 

646 """ 

647 Generate type-checkable Python source from a sygaldry config. 

648 

649 :param config: Loaded, interpolated config mapping. 

650 :param line_length: Maximum line length for generated code. 

651 :param call_target: Top-level config key to emit a call expression for. 

652 :param call_method: Method name to call on the target. 

653 :param call_args: Arguments for the call expression. 

654 :type config: dict 

655 :type line_length: int 

656 :returns: ``(source_code, mappings)`` tuple. 

657 """ 

658 analyzer = ConfigAnalyzer(config) 

659 result = analyzer.analyze() 

660 generator = CodeGenerator(result, line_length=line_length) 

661 return generator.generate( 

662 call_target=call_target, 

663 call_method=call_method, 

664 call_args=call_args, 

665 )