Coverage for sygaldry / codegen.py: 89%

304 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 00:13 -0400

1from __future__ import annotations 

2 

3__author__ = "Rohan B. Dalton" 

4 

5import re 

6from dataclasses import dataclass, field 

7from typing import Any, Optional 

8 

9from .types import RESERVED_KEYS 

10 

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

12 

13 

14@dataclass(frozen=True) 

15class SourceMapping: 

16 """ 

17 Map a generated line number to a config path. 

18 """ 

19 

20 line: int 

21 config_path: str 

22 

23 

24@dataclass 

25class ComponentEntry: 

26 """ 

27 A typed component extracted from the config. 

28 """ 

29 

30 config_path: str 

31 var_name: str 

32 import_path: str 

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

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

35 is_func: bool = False 

36 

37 

38@dataclass 

39class PlainEntry: 

40 """ 

41 A plain (non-typed) config value. 

42 """ 

43 

44 config_path: str 

45 var_name: str 

46 value: Any 

47 

48 

49@dataclass 

50class RefExpression: 

51 """ 

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

53 """ 

54 

55 target: str 

56 

57 

58@dataclass 

59class EntriesExpression: 

60 """ 

61 A dict built from _entries. 

62 """ 

63 

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

65 

66 

67@dataclass 

68class AnalysisResult: 

69 """ 

70 Result of analyzing a config dict. 

71 """ 

72 

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

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

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

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

77 

78 

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

80 """ 

81 Convert a config key to a valid Python identifier. 

82 """ 

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

84 if result[0].isdigit(): 

85 result = f"_{result}" 

86 return result 

87 else: 

88 return "_unnamed" 

89 

90 

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

92 """ 

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

94 """ 

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

96 if len(parts) == 1: 

97 return parts[0], parts[0] 

98 else: 

99 return parts[0], parts[1] 

100 

101 

102class ConfigAnalyzer: 

103 """ 

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

105 """ 

106 

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

108 self._config = config 

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

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

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

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

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

114 self._anon_counter = 0 

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

116 

117 def analyze(self) -> AnalysisResult: 

118 """ 

119 Analyze the config and return structured results. 

120 """ 

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

122 self._analyze_top_level(key, value) 

123 order = self._topological_sort() 

124 result = AnalysisResult( 

125 components=self._components, 

126 plains=self._plains, 

127 topological_order=order, 

128 imports=self._imports, 

129 ) 

130 return result 

131 

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

133 """ 

134 Allocate a unique variable name. 

135 """ 

136 name = _to_identifier(preferred) 

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

138 self._used_names.add(name) 

139 return name 

140 else: 

141 counter = 0 

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

143 counter += 1 

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

145 self._used_names.add(unique) 

146 return unique 

147 

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

149 """ 

150 Allocate an anonymous variable name. 

151 """ 

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

153 self._anon_counter += 1 

154 self._used_names.add(name) 

155 return name 

156 

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

158 """ 

159 Analyze a single top-level config entry. 

160 """ 

161 variable_name = self._alloc_name(key) 

162 self._variable_names[key] = variable_name 

163 self._deps[key] = list() 

164 

165 if isinstance(value, dict): 

166 if "_ref" in value: 

167 ref_target = value["_ref"] 

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

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

170 self._plains.append( 

171 PlainEntry( 

172 config_path=key, 

173 var_name=variable_name, 

174 value=RefExpression(target=ref_target), 

175 ) 

176 ) 

177 elif "_type" in value: 

178 self._analyze_type_entry(key, variable_name, value) 

179 elif "_func" in value: 

180 self._analyze_func_entry(key, variable_name, value) 

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

182 self._plains.append(PlainEntry(config_path=key, var_name=variable_name, value=...)) 

183 elif "_entries" in value: 

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

185 self._plains.append( 

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

187 ) 

188 else: 

189 self._plains.append( 

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

191 ) 

192 else: 

193 self._plains.append(PlainEntry(config_path=key, var_name=variable_name, value=value)) 

194 

195 def _analyze_type_entry( 

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

197 ) -> None: 

198 """ 

199 Analyze a _type entry. 

200 """ 

201 import_path = entry["_type"] 

202 mod, name = _split_import_path(import_path) 

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

204 

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

206 args = list() 

207 for idx, arg in enumerate(raw_args): 

208 processed = self._process_arg_value( 

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

210 ) 

211 args.append(processed) 

212 

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

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

215 if key in RESERVED_KEYS: 

216 continue 

217 processed = self._process_arg_value( 

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

219 ) 

220 kwargs[key] = processed 

221 

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

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

224 processed = self._process_arg_value( 

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

226 ) 

227 kwargs[key] = processed 

228 

229 self._components.append( 

230 ComponentEntry( 

231 config_path=config_path, 

232 var_name=var_name, 

233 import_path=import_path, 

234 args=args, 

235 kwargs=kwargs, 

236 ) 

237 ) 

238 

239 def _analyze_func_entry( 

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

241 ) -> None: 

242 """ 

243 Analyze a _func entry. 

244 """ 

245 import_path = value["_func"] 

246 module, name = _split_import_path(import_path) 

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

248 component = ComponentEntry( 

249 config_path=config_path, 

250 var_name=var_name, 

251 import_path=import_path, 

252 is_func=True, 

253 ) 

254 self._components.append(component) 

255 

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

257 """ 

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

259 

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

261 or hoists nested _type entries to intermediate variables. 

262 """ 

263 if not isinstance(value, dict): 

264 if isinstance(value, list): 

265 return [ 

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

267 for idx, item in enumerate(value) 

268 ] 

269 return value 

270 

271 if "_ref" in value: 

272 ref_target = value["_ref"] 

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

274 deps.append(top) 

275 return RefExpression(target=ref_target) 

276 

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

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

279 self._analyze_type_entry(config_path, anon_name, value) 

280 return RefExpression(target=anon_name) 

281 

282 elif "_func" in value: 

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

284 self._analyze_func_entry(config_path, anon_name, value) 

285 return RefExpression(target=anon_name) 

286 

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

288 return ... 

289 

290 elif "_entries" in value: 

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

292 

293 else: 

294 return value 

295 

296 def _analyze_entries( 

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

298 ) -> EntriesExpression: 

299 """ 

300 Analyze an _entries list. 

301 """ 

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

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

304 return EntriesExpression(items=[]) 

305 for idx, entry in enumerate(entries): 

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

307 continue 

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

309 value = self._process_arg_value( 

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

311 ) 

312 item = (key, value) 

313 items.append(item) 

314 return EntriesExpression(items=items) 

315 

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

317 """ 

318 Sort entries so that dependencies come before dependents. 

319 """ 

320 visited: set[str] = set() 

321 order: list[str] = list() 

322 visiting: set[str] = set() 

323 

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

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

326 

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

328 # What the absolute fuck?:w 

329 if key_ in visited or key_ in visiting: 

330 return 

331 

332 visiting.add(key_) 

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

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

335 visit(dep) 

336 visiting.discard(key_) 

337 visited.add(key_) 

338 order.append(key_) 

339 

340 for key in keys: 

341 visit(key) 

342 

343 return order 

344 

345 

346class CodeGenerator: 

347 """ 

348 Generate Python source code from analysis results. 

349 """ 

350 

351 def __init__(self, analysis: AnalysisResult) -> None: 

352 self._analysis = analysis 

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

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

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

356 

357 for component in analysis.components: 

358 self._entry_map[component.config_path] = component 

359 for plain in analysis.plains: 

360 self._entry_map[plain.config_path] = plain 

361 for component in analysis.components: 

362 self._entry_map[component.var_name] = component 

363 for plain in analysis.plains: 

364 self._entry_map[plain.var_name] = plain 

365 

366 def generate(self) -> tuple[str, list[SourceMapping]]: 

367 """ 

368 Generate the full source code. 

369 """ 

370 self._emit_header() 

371 self._emit_imports() 

372 self._emit_blank() 

373 self._emit_entries() 

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

375 

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

377 """ 

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

379 """ 

380 self._lines.append(line) 

381 if config_path is not None: 

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

383 self._mappings.append(mapping) 

384 

385 def _emit_blank(self) -> None: 

386 self._lines.append("") 

387 

388 def _emit_header(self) -> None: 

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

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

391 

392 def _emit_imports(self) -> None: 

393 seen: set[str] = set() 

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

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

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

397 seen.add(statement) 

398 self._emit(statement) 

399 

400 def _emit_entries(self) -> None: 

401 """ 

402 Emit all entries in topological order. 

403 """ 

404 emitted: set[str] = set() 

405 for config_path in self._analysis.topological_order: 

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

407 continue 

408 entry = self._entry_map.get(config_path) 

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

410 continue 

411 

412 emitted.add(config_path) 

413 

414 if isinstance(entry, ComponentEntry): 

415 self._emit_component(entry) 

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

417 self._emit_plain(entry) 

418 

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

420 """ 

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

422 """ 

423 _, name = _split_import_path(comp.import_path) 

424 if comp.is_func: 

425 self._emit( 

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

427 config_path=comp.config_path, 

428 ) 

429 return 

430 

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

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

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

434 

435 args_str = ", ".join(parts) 

436 self._emit( 

437 f"{comp.var_name}: {name} = {name}({args_str}) # config: {comp.config_path}", 

438 config_path=comp.config_path, 

439 ) 

440 

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

442 """ 

443 Emit a plain value assignment. 

444 """ 

445 if isinstance(plain.value, RefExpression): 

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

447 self._emit( 

448 f"{plain.var_name} = {target} # config: {plain.config_path}", 

449 config_path=plain.config_path, 

450 ) 

451 elif plain.value is ...: 

452 self._emit( 

453 f"{plain.var_name}: Any = ... # config: {plain.config_path}", 

454 config_path=plain.config_path, 

455 ) 

456 elif isinstance(plain.value, EntriesExpression): 

457 items_str = ", ".join( 

458 f"{self._value_to_source(key)}: {self._value_to_source(value)}" 

459 for key, value in plain.value.items 

460 ) 

461 self._emit( 

462 f"{plain.var_name}: dict = {{{items_str}}} # config: {plain.config_path}", 

463 config_path=plain.config_path, 

464 ) 

465 else: 

466 type_name = self._python_type_name(plain.value) 

467 self._emit( 

468 f"{plain.var_name}: {type_name} = {self._value_to_source(plain.value)}" 

469 f" # config: {plain.config_path}", 

470 config_path=plain.config_path, 

471 ) 

472 

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

474 """ 

475 Convert a ref target to a Python expression. 

476 """ 

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

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

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

480 var_name = entry.var_name 

481 else: 

482 var_name = _to_identifier(top) 

483 else: 

484 var_name = _to_identifier(top) 

485 if remainder: 

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

487 return var_name 

488 

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

490 """ 

491 Convert a config value to its Python source representation. 

492 """ 

493 if isinstance(value, RefExpression): 

494 return self._resolve_ref_source(value.target) 

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

496 items_str = ", ".join( 

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

498 for k, v in value.items 

499 ) 

500 return f"{{{items_str}}}" 

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

502 return "..." 

503 elif value is None: 

504 return "None" 

505 elif isinstance(value, bool): 

506 return "True" if value else "False" 

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

508 return repr(value) 

509 elif isinstance(value, list): 

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

511 return f"[{items}]" 

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

513 items = ", ".join( 

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

515 for k, v in value.items() 

516 ) 

517 return f"{{{items}}}" 

518 else: 

519 return repr(value) 

520 

521 @staticmethod 

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

523 """ 

524 Get a type annotation name for a plain value. 

525 """ 

526 if value is None: 

527 return "None" 

528 elif isinstance(value, bool): 

529 return "bool" 

530 elif isinstance(value, int): 

531 return "int" 

532 elif isinstance(value, float): 

533 return "float" 

534 elif isinstance(value, str): 

535 return "str" 

536 elif isinstance(value, list): 

537 return "list" 

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

539 return "dict" 

540 else: 

541 return "Any" 

542 

543 

544def generate_check_source( 

545 config: dict[str, Any], 

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

547 """ 

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

549 

550 :param config: Loaded, interpolated config mapping. 

551 :type config: dict 

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

553 """ 

554 analyzer = ConfigAnalyzer(config) 

555 result = analyzer.analyze() 

556 generator = CodeGenerator(result) 

557 return generator.generate()