Coverage for klayout_pex/pex25d/pex25d_cli.py: 84%

220 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 19:08 +0000

1# 

2# -------------------------------------------------------------------------------- 

3# SPDX-FileCopyrightText: 2024-2026 Martin Jan Köhler and Harald Pretl 

4# Johannes Kepler University, Institute for Integrated Circuits. 

5# 

6# This file is part of KPEX  

7# (see https://github.com/iic-jku/klayout-pex). 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21# SPDX-License-Identifier: GPL-3.0-or-later 

22# -------------------------------------------------------------------------------- 

23# 

24 

25""" 

26``pex25d`` — the standalone PEX25D format tool. 

27 

28Deliberately *not* part of ``kpex``. Everything this tool does is format work: 

29 - validating 

30 - converting between encodings 

31 - resolving a file into a scene 

32 - writing a scene as a solver's native input 

33 - looking at what is inside 

34None of it needs a layout, an LVS run, or KLayout — and that is the point. 

35 

36Another group can install this, check their own custom PEX25D writer against the reference validator, 

37and never take on the rest of kpex and its dependencies. 

38 

39*Generating* PEX25D from a layout however need all of that machinery, so it lives in ``kpex pex25d`` instead. 

40""" 

41 

42from __future__ import annotations 

43 

44import argparse 

45import contextlib 

46import shlex 

47import sys 

48from typing import * 

49 

50import rich.console 

51import rich.markdown 

52import rich.text 

53from rich_argparse import RichHelpFormatter 

54 

55from ..log import ( 

56 LogLevel, 

57 set_log_level, 

58 info, 

59 warning, 

60 error, 

61 rule, 

62 subproc, 

63) 

64from ..util.argparse_helpers import render_enum_help, true_or_false 

65from ..version import __version__ 

66 

67from .exporters import ExportError, ExporterOptions, SolverTarget 

68from .artifact import ( 

69 ArtifactFormat, 

70 ArtifactKind, 

71 ArtifactNamingError, 

72 ArtifactSpec, 

73 STDIO_PATH, 

74 infer_artifact_spec, 

75) 

76from .protobuf import ProtobufNotGeneratedError 

77from .reader import ReadError 

78from .resolver import ResolveError 

79from .diagnostics import ( 

80 DiagnosticsFormat, 

81 DiagnosticsReport, 

82 ExitCode, 

83 diagnostics_stream, 

84) 

85 

86 

87PROGRAM_NAME = "pex25d" 

88 

89 

90class ArgumentValidationError(Exception): 

91 pass 

92 

93 

94EPILOG_MARKDOWN = """ 

95| Exit code | Meaning | 

96| --------- | ------- | 

97| 0 | success (warnings do not fail a run unless `--werror` is given) | 

98| 1 | input is not valid PEX25D | 

99| 2 | command line is wrong, or an input could not be read | 

100| 3 | requested operation is not implemented yet | 

101 

102A path of `-` means stdin for inputs and stdout for outputs.  

103When an artifact goes to stdout, diagnostics go to stderr instead,  

104so the stream stays a valid PEX25D artifact. 

105 

106Artifact naming, inferred from the file name and overridable with 

107`--in_kind` / `--in_format` / `--out_kind` / `--out_format`: 

108 

109| Name | Holds | Encoding | 

110| ---- | ----- | -------- | 

111| `NAME.pex25d` | PEX25DFile | PEX25D text format | 

112| `NAME.pex25d.pb` | PEX25DFile | binary protobuf | 

113| `NAME.pex25d.textpb` | PEX25DFile | protobuf text format | 

114| `NAME.pex25d.scene.pb` | PEX25DScene | binary protobuf | 

115| `NAME.pex25d.scene.textpb` | PEX25DScene | protobuf text format | 

116 

117A trailing `.gz` is honoured on any of them.  

118There is deliberately no text-format spelling of a scene:  

119the text format is the unresolved one, and resolution is not reversible. 

120""" 

121 

122 

123def _epilog() -> rich.console.Group: 

124 return rich.console.Group( 

125 rich.text.Text('Exit codes and file naming:', style='argparse.groups'), 

126 rich.markdown.Markdown(EPILOG_MARKDOWN, style='argparse.text') 

127 ) 

128 

129 

130class Pex25DCLI: 

131 # ------------------------------------------------------------------ parsing 

132 

133 @staticmethod 

134 def _add_special_options(parser: argparse.ArgumentParser) -> None: 

135 group = parser.add_argument_group("Special Options") 

136 group.add_argument("--help", "-h", action='help', 

137 help="show this help message and exit") 

138 group.add_argument("--version", "-v", action='version', 

139 version=f'{PROGRAM_NAME} {__version__}') 

140 group.add_argument("--log_level", dest='log_level', default='subprocess', 

141 help=render_enum_help(topic='log_level', enum_cls=LogLevel)) 

142 

143 @staticmethod 

144 def _add_input_argument(parser: argparse.ArgumentParser) -> None: 

145 parser.add_argument("input_path", type=str, metavar='INPUT', 

146 help="Input PEX25D artifact ('-' for stdin)") 

147 group = parser.add_argument_group("Input Interpretation") 

148 group.add_argument("--in_kind", dest='in_kind', 

149 default=ArtifactKind.AUTO, type=ArtifactKind, 

150 choices=list(ArtifactKind), 

151 help="Which message the input holds " 

152 "(default is '%(default)s', i.e. inferred from the file name)") 

153 group.add_argument("--in_format", dest='in_format', 

154 default=ArtifactFormat.AUTO, type=ArtifactFormat, 

155 choices=list(ArtifactFormat), 

156 help="How the input is encoded " 

157 "(default is '%(default)s', i.e. inferred from the file name)") 

158 group.add_argument("--with_source_refs", dest='with_source_refs', 

159 action='store_true', default=False, 

160 help="Record where each record came from while reading the " 

161 "text format (default is %(default)s). Useful when an " 

162 "INCLUDE tree has been flattened; diagnostics carry " 

163 "positions either way.") 

164 

165 @staticmethod 

166 def _add_output_arguments(parser: argparse.ArgumentParser, 

167 required: bool = True) -> None: 

168 group = parser.add_argument_group("Output") 

169 group.add_argument("--output", "-o", dest='output_path', 

170 required=required, metavar='PATH', 

171 help="Output path ('-' for stdout)") 

172 group.add_argument("--out_kind", dest='out_kind', 

173 default=ArtifactKind.AUTO, type=ArtifactKind, 

174 choices=list(ArtifactKind), 

175 help="Which message to write " 

176 "(default is '%(default)s', i.e. inferred from the file name)") 

177 group.add_argument("--out_format", dest='out_format', 

178 default=ArtifactFormat.AUTO, type=ArtifactFormat, 

179 choices=list(ArtifactFormat), 

180 help="How to encode the output " 

181 "(default is '%(default)s', i.e. inferred from the file name)") 

182 group.add_argument("--comments", dest='comments', 

183 action='store_true', default=False, 

184 help="Include the syntax hints from the specification as " 

185 "comments (default is %(default)s). Text format only.") 

186 

187 @staticmethod 

188 def _add_diagnostics_arguments(parser: argparse.ArgumentParser) -> None: 

189 group = parser.add_argument_group( 

190 "Diagnostics", 

191 description="'json' and 'pb' emit stable PEX25D-Ennnn codes and are the " 

192 "supported way to check a PEX25D writer in CI; 'human' output " 

193 "is for reading, and its wording is not stable." 

194 ) 

195 group.add_argument("--diagnostics", dest='diagnostics_format', 

196 default=DiagnosticsFormat.DEFAULT, type=DiagnosticsFormat, 

197 choices=list(DiagnosticsFormat), 

198 help=render_enum_help(topic='diagnostics', enum_cls=DiagnosticsFormat)) 

199 group.add_argument("--diagnostics_out", dest='diagnostics_path', default=None, 

200 metavar='PATH', 

201 help="Write diagnostics here instead of to the console " 

202 "('-' for stdout)") 

203 group.add_argument("--strict", dest='strict', 

204 action='store_true', default=False, 

205 help="Also run the geometric tier: ring and box " 

206 "wellformedness, hole containment, conductor " 

207 "overlap (default is %(default)s)") 

208 group.add_argument("--werror", dest='warnings_are_errors', 

209 action='store_true', default=False, 

210 help="Treat warnings as errors for the exit code " 

211 "(default is %(default)s)") 

212 

213 def parse_args(self, arg_list: List[str] = None) -> argparse.Namespace: 

214 main_parser = argparse.ArgumentParser( 

215 description=f"{PROGRAM_NAME}: PEX25D format tool for KLayout-PEX", 

216 prog=PROGRAM_NAME, 

217 add_help=False, 

218 formatter_class=RichHelpFormatter, 

219 epilog=_epilog(), 

220 ) 

221 self._add_special_options(main_parser) 

222 

223 subparsers = main_parser.add_subparsers(dest="command", metavar='<subcommand>', 

224 help="Sub-commands help") 

225 

226 # ---------------------------------------------------------- validate 

227 parser_validate = subparsers.add_parser( 

228 "validate", 

229 help="Check a PEX25D artifact against the reference validator", 

230 description="Check a PEX25D artifact and report coded diagnostics. " 

231 "Exits 1 if the file is invalid, 0 if it is not.", 

232 add_help=False, formatter_class=RichHelpFormatter) 

233 parser_validate.add_argument("--help", "-h", action='help', 

234 help="show this help message and exit") 

235 self._add_input_argument(parser_validate) 

236 self._add_diagnostics_arguments(parser_validate) 

237 

238 # ----------------------------------------------------------- convert 

239 parser_convert = subparsers.add_parser( 

240 "convert", 

241 help="Re-encode a PEX25D artifact (text / pb / textpb)", 

242 description="Change how a PEX25D artifact is encoded, without changing " 

243 "what it says. To turn a file into a scene use 'resolve'; to " 

244 "leave PEX25D for a solver use 'export'.", 

245 add_help=False, formatter_class=RichHelpFormatter) 

246 parser_convert.add_argument("--help", "-h", action='help', 

247 help="show this help message and exit") 

248 self._add_input_argument(parser_convert) 

249 self._add_output_arguments(parser_convert) 

250 

251 # ----------------------------------------------------------- resolve 

252 parser_resolve = subparsers.add_parser( 

253 "resolve", 

254 help="Resolve a PEX25DFile into a PEX25DScene", 

255 description="Resolve CONNECTS / BETWEEN / WRAPS, flatten wrap depth and " 

256 "compute terminal intersections, producing the scene that " 

257 "solver adapters consume.", 

258 add_help=False, formatter_class=RichHelpFormatter) 

259 parser_resolve.add_argument("--help", "-h", action='help', 

260 help="show this help message and exit") 

261 self._add_input_argument(parser_resolve) 

262 self._add_output_arguments(parser_resolve) 

263 self._add_diagnostics_arguments(parser_resolve) 

264 

265 # ------------------------------------------------------------ export 

266 parser_export = subparsers.add_parser( 

267 "export", 

268 help="Export a PEX25D scene as a solver's native input files", 

269 description="Export the scene to an engine's own input format. Does " 

270 "not run the engine — that is 'kpex extract'.", 

271 add_help=False, formatter_class=RichHelpFormatter) 

272 parser_export.add_argument("--help", "-h", action='help', 

273 help="show this help message and exit") 

274 self._add_input_argument(parser_export) 

275 parser_export.add_argument("--to", dest='solver_target', required=True, 

276 type=SolverTarget, choices=list(SolverTarget), 

277 help=render_enum_help(topic='to', 

278 enum_cls=SolverTarget)) 

279 parser_export.add_argument("--out_dir", dest='output_dir_path', required=True, 

280 help="Directory to export the solver input files into") 

281 parser_export.add_argument("--prefix", dest='prefix', default='', 

282 help="Prefix for the generated file names " 

283 "(default is the target's own)") 

284 parser_export.add_argument("--field_margin", dest='field_margin', 

285 type=float, default=8.0, metavar='UM', 

286 help="How far to draw the laterally unbounded " 

287 "materials beyond the geometry, in µm " 

288 "(default is %(default)s). Ignored when the " 

289 "scene carries a DOMAIN_BOX.") 

290 parser_export.add_argument("--delaunay_amax", dest='delaunay_amax', 

291 type=float, default=0.0, metavar='AREA', 

292 help="Maximum triangle area (default is " 

293 "%(default)s, i.e. unconstrained)") 

294 parser_export.add_argument("--delaunay_b", dest='delaunay_b', 

295 type=float, default=1.0, metavar='B', 

296 help="Minimum mesh angle as b = 2·sin(angle) " 

297 "(default is %(default)s, i.e. 30 degrees)") 

298 parser_export.add_argument("--stl", dest='write_stl', 

299 action='store_true', default=False, 

300 help="Also dump the generated solids as STL") 

301 parser_export.add_argument("--geo_check", dest='geometry_check', 

302 action='store_true', default=False, 

303 help="Validate the geometry before writing") 

304 self._add_diagnostics_arguments(parser_export) 

305 

306 # -------------------------------------------------------------- show 

307 parser_show = subparsers.add_parser( 

308 "show", 

309 help="Summarize what is inside a PEX25D artifact", 

310 description="Print a human-readable summary. For machine consumption use " 

311 "'convert --out_format textpb' instead — this output is not stable.", 

312 add_help=False, formatter_class=RichHelpFormatter) 

313 parser_show.add_argument("--help", "-h", action='help', 

314 help="show this help message and exit") 

315 self._add_input_argument(parser_show) 

316 parser_show.add_argument("--section", dest='sections', action='append', 

317 default=None, metavar='NAME', 

318 choices=['header', 'meta', 'layers', 'dielectrics', 

319 'conductors', 'terminals', 'resistance', 

320 'domain', 'all'], 

321 help="Section to print; repeatable (default is 'all')") 

322 

323 if arg_list is None: 

324 arg_list = sys.argv[1:] 

325 args = main_parser.parse_args(arg_list) 

326 

327 if args.command is None: 

328 main_parser.print_help() 

329 sys.exit(ExitCode.USAGE) 

330 

331 self.validate_args(args) 

332 return args 

333 

334 # --------------------------------------------------------------- validation 

335 

336 @staticmethod 

337 def validate_args(args: argparse.Namespace) -> None: 

338 found_errors = False 

339 

340 try: 

341 args.log_level = LogLevel[args.log_level.upper()] 

342 except KeyError: 

343 error(f"Requested log level {args.log_level.lower()} does not exist, " 

344 f"{render_enum_help(topic='log_level', enum_cls=LogLevel, print_default=False)}") 

345 found_errors = True 

346 

347 # Input spec. A scene default for stdin would be wrong: 

348 # the only thing you can pipe in without a file name to inspect is what another tool wrote, 

349 # and the format's own serialization is the text file. 

350 try: 

351 args.input_spec = infer_artifact_spec(args.input_path, 

352 kind=args.in_kind, 

353 format=args.in_format) 

354 except ArtifactNamingError as e: 

355 error(str(e)) 

356 found_errors = True 

357 

358 if hasattr(args, 'output_path') and args.output_path is not None: 

359 default_kind = ArtifactKind.SCENE if args.command == 'resolve' else ArtifactKind.FILE 

360 default_format = ArtifactFormat.PB if default_kind == ArtifactKind.SCENE \ 

361 else ArtifactFormat.TEXT 

362 try: 

363 args.output_spec = infer_artifact_spec(args.output_path, 

364 kind=args.out_kind, 

365 format=args.out_format, 

366 default_kind=default_kind, 

367 default_format=default_format) 

368 except ArtifactNamingError as e: 

369 error(str(e)) 

370 found_errors = True 

371 

372 if args.command == 'resolve' and getattr(args, 'output_spec', None) is not None: 

373 if args.output_spec.kind != ArtifactKind.SCENE: 

374 error("'resolve' produces a PEX25DScene; the output path or --out_kind asks for a PEX25DFile.") 

375 found_errors = True 

376 

377 if found_errors: 

378 raise ArgumentValidationError("Argument validation failed") 

379 

380 # -------------------------------------------------------------------- verbs 

381 

382 @staticmethod 

383 def _load(args: argparse.Namespace, report: DiagnosticsReport) -> Any: 

384 from .codec import load_artifact 

385 return load_artifact(args.input_spec, report=report) 

386 

387 def run_validate(self, args: argparse.Namespace, report: DiagnosticsReport) -> None: 

388 from .validator import validate 

389 message = self._load(args, report) 

390 validate(message, report=report, strict=args.strict) 

391 

392 def run_convert(self, args: argparse.Namespace, report: DiagnosticsReport) -> None: 

393 from .codec import load_artifact, save_artifact 

394 

395 if args.input_spec.kind != args.output_spec.kind: 

396 raise ArgumentValidationError( 

397 f"'convert' re-encodes, it does not transform: input is a " 

398 f"{args.input_spec.kind.value}, output would be a " 

399 f"{args.output_spec.kind.value}. Use 'resolve' to turn a file into a scene." 

400 ) 

401 

402 message = load_artifact(args.input_spec, report=report, 

403 with_source_refs=args.with_source_refs) 

404 save_artifact(message, args.output_spec, comments=args.comments) 

405 if not args.output_spec.is_stdio: 

406 info(f"Wrote {args.output_spec}") 

407 

408 def run_resolve(self, args: argparse.Namespace, report: DiagnosticsReport) -> None: 

409 from .codec import load_artifact, save_artifact 

410 from .resolver import resolve 

411 

412 message = load_artifact(args.input_spec, report=report, 

413 with_source_refs=args.with_source_refs) 

414 scene = resolve(message, report=report, strict=args.strict) 

415 save_artifact(scene, args.output_spec, comments=args.comments) 

416 if not args.output_spec.is_stdio: 

417 info(f"Wrote {args.output_spec}") 

418 

419 def run_export(self, args: argparse.Namespace, report: DiagnosticsReport) -> None: 

420 from .exporters import export 

421 from .codec import load_artifact 

422 from .resolver import resolve 

423 

424 message = load_artifact(args.input_spec, report=report, 

425 with_source_refs=args.with_source_refs) 

426 if args.input_spec.kind == ArtifactKind.FILE: 

427 info("Input is an unresolved PEX25DFile, resolving it first") 

428 message = resolve(message, report=report, strict=args.strict) 

429 

430 written = export(message, 

431 target=args.solver_target, 

432 output_dir_path=args.output_dir_path, 

433 prefix=args.prefix) 

434 for path in written: 

435 subproc(path) 

436 info(f"Wrote {len(written)} {args.solver_target.value} input file(s) to {args.output_dir_path}") 

437 

438 def run_show(self, args: argparse.Namespace, report: DiagnosticsReport) -> None: 

439 from .codec import load_artifact 

440 from .show import show 

441 

442 message = load_artifact(args.input_spec, report=report, 

443 with_source_refs=args.with_source_refs) 

444 show(message, kind=args.input_spec.kind, sections=args.sections or ['all']) 

445 

446 # --------------------------------------------------------------------- main 

447 

448 def main(self, argv: List[str]) -> None: 

449 try: 

450 args = self.parse_args(argv[1:]) 

451 except ArgumentValidationError: 

452 sys.exit(ExitCode.USAGE) 

453 

454 set_log_level(args.log_level) 

455 

456 # When the artifact goes to stdout, stdout belongs to the artifact and to 

457 # nothing else — one stray log line and the consumer of the pipe is parsing garbage. 

458 # Redirecting sys.stdout to stderr for the whole run is therefore necessary: 

459 # the rich console resolves sys.stdout at write time, so every info()/rule()/warning() follows, 

460 # including ones written by code that never considered being in a pipeline. 

461 # The artifact I/O itself uses sys.__stdout__ and is unaffected. 

462 output_spec: Optional[ArtifactSpec] = getattr(args, 'output_spec', None) 

463 artifact_on_stdout = output_spec is not None and output_spec.is_stdio 

464 

465 with contextlib.ExitStack() as stack: 

466 if artifact_on_stdout: 

467 stack.enter_context(contextlib.redirect_stdout(sys.stderr)) 

468 self._run(args) 

469 

470 def _run(self, args: argparse.Namespace) -> None: 

471 if args.input_spec.path != STDIO_PATH: 

472 rule('Command line arguments') 

473 subproc(' '.join(map(shlex.quote, sys.argv))) 

474 

475 report = DiagnosticsReport( 

476 warnings_are_errors=getattr(args, 'warnings_are_errors', False)) 

477 

478 handler = { 

479 'validate': self.run_validate, 

480 'convert': self.run_convert, 

481 'resolve': self.run_resolve, 

482 'export': self.run_export, 

483 'show': self.run_show, 

484 }[args.command] 

485 

486 try: 

487 handler(args, report) 

488 except NotImplementedError as e: 

489 error(str(e)) 

490 sys.exit(ExitCode.NOT_IMPLEMENTED) 

491 except ArgumentValidationError as e: 

492 error(str(e)) 

493 sys.exit(ExitCode.USAGE) 

494 except ExportError as e: 

495 error(str(e)) 

496 sys.exit(ExitCode.USAGE) 

497 except (ReadError, ResolveError) as e: 

498 error(str(e)) 

499 self._emit_diagnostics(args, report) 

500 sys.exit(ExitCode.DIAGNOSTIC_ERRORS) 

501 except ProtobufNotGeneratedError as e: 

502 error(str(e)) 

503 sys.exit(ExitCode.USAGE) 

504 except (OSError, ValueError) as e: 

505 error(f"Failed to process {args.input_spec}: {e}") 

506 sys.exit(ExitCode.USAGE) 

507 

508 self._emit_diagnostics(args, report) 

509 sys.exit(report.exit_code) 

510 

511 @staticmethod 

512 def _emit_diagnostics(args: argparse.Namespace, 

513 report: DiagnosticsReport) -> None: 

514 diagnostics_format: DiagnosticsFormat = getattr( 

515 args, 'diagnostics_format', DiagnosticsFormat.HUMAN) 

516 

517 output_spec: Optional[ArtifactSpec] = getattr(args, 'output_spec', None) 

518 artifact_on_stdout = output_spec is not None and output_spec.is_stdio 

519 

520 diagnostics_path: Optional[str] = getattr(args, 'diagnostics_path', None) 

521 if diagnostics_path is not None and diagnostics_path != STDIO_PATH: 

522 mode = 'wb' if diagnostics_format == DiagnosticsFormat.PB else 'w' 

523 with open(diagnostics_path, mode) as f: 

524 report.render(diagnostics_format, stream=f) 

525 return 

526 

527 if diagnostics_format == DiagnosticsFormat.HUMAN: 

528 # 'validate' answers with its diagnostics, so an explicit "none" is the 

529 # result. For the other verbs a clean run should simply be quiet. 

530 if report.diagnostics or args.command == 'validate': 

531 report.render(diagnostics_format) 

532 return 

533 

534 with diagnostics_stream(writes_to_stdout=artifact_on_stdout) as stream: 

535 report.render(diagnostics_format, stream=stream)