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
« 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#
25"""
26``pex25d`` — the standalone PEX25D format tool.
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.
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.
39*Generating* PEX25D from a layout however need all of that machinery, so it lives in ``kpex pex25d`` instead.
40"""
42from __future__ import annotations
44import argparse
45import contextlib
46import shlex
47import sys
48from typing import *
50import rich.console
51import rich.markdown
52import rich.text
53from rich_argparse import RichHelpFormatter
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__
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)
87PROGRAM_NAME = "pex25d"
90class ArgumentValidationError(Exception):
91 pass
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 |
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.
106Artifact naming, inferred from the file name and overridable with
107`--in_kind` / `--in_format` / `--out_kind` / `--out_format`:
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 |
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"""
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 )
130class Pex25DCLI:
131 # ------------------------------------------------------------------ parsing
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))
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.")
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.")
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)")
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)
223 subparsers = main_parser.add_subparsers(dest="command", metavar='<subcommand>',
224 help="Sub-commands help")
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)
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)
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)
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)
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')")
323 if arg_list is None:
324 arg_list = sys.argv[1:]
325 args = main_parser.parse_args(arg_list)
327 if args.command is None:
328 main_parser.print_help()
329 sys.exit(ExitCode.USAGE)
331 self.validate_args(args)
332 return args
334 # --------------------------------------------------------------- validation
336 @staticmethod
337 def validate_args(args: argparse.Namespace) -> None:
338 found_errors = False
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
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
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
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
377 if found_errors:
378 raise ArgumentValidationError("Argument validation failed")
380 # -------------------------------------------------------------------- verbs
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)
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)
392 def run_convert(self, args: argparse.Namespace, report: DiagnosticsReport) -> None:
393 from .codec import load_artifact, save_artifact
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 )
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}")
408 def run_resolve(self, args: argparse.Namespace, report: DiagnosticsReport) -> None:
409 from .codec import load_artifact, save_artifact
410 from .resolver import resolve
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}")
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
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)
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}")
438 def run_show(self, args: argparse.Namespace, report: DiagnosticsReport) -> None:
439 from .codec import load_artifact
440 from .show import show
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'])
446 # --------------------------------------------------------------------- main
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)
454 set_log_level(args.log_level)
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
465 with contextlib.ExitStack() as stack:
466 if artifact_on_stdout:
467 stack.enter_context(contextlib.redirect_stdout(sys.stderr))
468 self._run(args)
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)))
475 report = DiagnosticsReport(
476 warnings_are_errors=getattr(args, 'warnings_are_errors', False))
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]
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)
508 self._emit_diagnostics(args, report)
509 sys.exit(report.exit_code)
511 @staticmethod
512 def _emit_diagnostics(args: argparse.Namespace,
513 report: DiagnosticsReport) -> None:
514 diagnostics_format: DiagnosticsFormat = getattr(
515 args, 'diagnostics_format', DiagnosticsFormat.HUMAN)
517 output_spec: Optional[ArtifactSpec] = getattr(args, 'output_spec', None)
518 artifact_on_stdout = output_spec is not None and output_spec.is_stdio
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
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
534 with diagnostics_stream(writes_to_stdout=artifact_on_stdout) as stream:
535 report.render(diagnostics_format, stream=stream)