Coverage for klayout_pex/pex25d/pex25d_cli.py: 96%
338 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +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 - importing another tool's scene description, via importer plugins
33 - writing a scene as a solver's native input
34 - looking at what is inside
35None of it needs a layout, an LVS run, or KLayout — and that is the point.
37Another group can install this, check their own custom PEX25D writer against the reference validator,
38and never take on the rest of kpex and its dependencies.
40*Generating* PEX25D from a layout however need all of that machinery, so it lives in ``kpex pex25d`` instead.
41"""
43from __future__ import annotations
45import argparse
46import contextlib
47import json
48import os
49import shlex
50import sys
51from typing import *
53import rich.console
54import rich.markdown
55import rich.table
56import rich.text
57from rich_argparse import RichHelpFormatter
59from ..log import (
60 console,
61 set_log_level,
62 debug,
63 info,
64 warning,
65 error,
66 rule,
67 subproc,
68)
69from ..util.argparse_helpers import (
70 SectionedHelpFormatter,
71 add_argument_with_former_name,
72 add_help_subcommand,
73 add_log_level_argument,
74 add_subcommand_sections,
75 handle_help_subcommand,
76 render_enum_help,
77 true_or_false,
78 warn_deprecated_options,
79)
80from ..version import __version__
82from .exporters import ExportError, ExporterExecutionError
83from .importers import ImporterError, ImporterExecutionError
84from .artifact import (
85 ArtifactFormat,
86 ArtifactKind,
87 ArtifactNamingError,
88 ArtifactSpec,
89 STDIO_PATH,
90 infer_artifact_spec,
91)
92from .protobuf import ProtobufNotGeneratedError
93from .reader import ReadError
94from .resolver import ResolveError
95from .diagnostics import (
96 DiagnosticsFormat,
97 DiagnosticsReport,
98 ExitCode,
99 diagnostics_stream,
100)
103PROGRAM_NAME = "pex25d"
106class ArgumentValidationError(Exception):
107 pass
110EPILOG_MARKDOWN = """
111| Exit code | Meaning |
112| --------- | ------- |
113| 0 | success (warnings do not fail a run unless `--werror` is given) |
114| 1 | input is not valid PEX25D |
115| 2 | command line is wrong, or an input could not be read |
116| 3 | requested operation is not implemented yet |
117| 4 | unexpected plugin failure |
119A path of `-` means stdin for inputs and stdout for outputs.
120When an artifact goes to stdout, diagnostics go to stderr instead,
121so the stream stays a valid PEX25D artifact.
123Artifact naming, inferred from the file name and overridable with
124`--in-kind` / `--in-format` / `--out-kind` / `--out-format`:
126| Name | Holds | Encoding |
127| ---- | ----- | -------- |
128| `NAME.pex25d` | PEX25DFile | PEX25D text format |
129| `NAME.pex25d.pb` | PEX25DFile | binary protobuf |
130| `NAME.pex25d.textpb` | PEX25DFile | protobuf text format |
131| `NAME.pex25d.scene.pb` | PEX25DScene | binary protobuf |
132| `NAME.pex25d.scene.textpb` | PEX25DScene | protobuf text format |
134A trailing `.gz` is honoured on any of them.
135There is deliberately no text-format spelling of a scene:
136the text format is the unresolved one, and resolution is not reversible.
137"""
140SUBCOMMAND_SECTIONS: Dict[str, Tuple[str, ...]] = {
141 'Inspect': ('show', 'validate'),
142 'Transform': ('convert', 'resolve'),
143 'Exchange with other tools': ('import', 'export'),
144 'Plugins': ('plugins', 'importers', 'exporters'),
145 'Help': ('help',),
146}
147"""How ``pex25d --help`` groups the subcommands; every subcommand is in exactly one section."""
150EXPORT_OPTION_FLAGS: Dict[str, str] = {
151 'field_margin_um': 'field_margin',
152 'delaunay_amax': 'delaunay_amax',
153 'delaunay_b': 'delaunay_b',
154 'write_stl': 'write_stl',
155 'geometry_check': 'geometry_check',
156}
157"""Exporter option → the ``pex25d export`` flag (argparse dest) that sets it."""
160def option_value(text: str) -> Any:
161 """An ``--option`` value: JSON where it parses (``10``, ``1e-9``, ``true``), else the text."""
162 try:
163 return json.loads(text)
164 except ValueError:
165 return text
168def _epilog() -> rich.console.Group:
169 return rich.console.Group(
170 rich.text.Text('Exit codes and file naming:', style='argparse.groups'),
171 rich.markdown.Markdown(EPILOG_MARKDOWN, style='argparse.text')
172 )
175def plugin_tables() -> List[rich.table.Table]:
176 """One table per plugin kind, from package metadata only: no plugin code is imported."""
177 from .exporters import exporter_registry
178 from .importers import importer_registry
180 tables: List[rich.table.Table] = []
181 for title, registry in (('Exporters', exporter_registry()), ('Importers', importer_registry())):
182 table = rich.table.Table(title=f"{title} ({registry.group})", title_justify='left')
183 table.add_column('Selector', no_wrap=True)
184 table.add_column('Package')
185 table.add_column('Version', no_wrap=True)
186 table.add_column('Description')
187 table.add_column('Entry point', overflow='fold')
188 for info in registry.plugins:
189 table.add_row(*(rich.text.Text(value) for value in (
190 registry.selector(info), info.distribution, info.version, info.summary, info.target)))
191 if not registry.plugins:
192 table.add_row('', '', '', rich.text.Text('none installed', style='dim'), '')
193 tables.append(table)
194 return tables
197class Pex25DCLI:
198 # ------------------------------------------------------------------ parsing
200 @staticmethod
201 def _add_special_options(parser: argparse.ArgumentParser) -> None:
202 group = parser.add_argument_group("Special Options")
203 group.add_argument("--help", "-h", action='help',
204 help="show this help message and exit")
205 group.add_argument("--version", "-v", action='version',
206 version=f'{PROGRAM_NAME} {__version__}')
207 add_log_level_argument(group)
209 @staticmethod
210 def _add_input_argument(parser: argparse.ArgumentParser) -> None:
211 parser.add_argument("input_path", type=str, metavar='INPUT',
212 help="Input PEX25D artifact ('-' for stdin)")
213 group = parser.add_argument_group("Input Interpretation")
214 add_argument_with_former_name(group, "--in-kind", "--in_kind", dest='in_kind',
215 default=ArtifactKind.AUTO, type=ArtifactKind,
216 choices=list(ArtifactKind),
217 help="Which message the input holds "
218 "(default is '%(default)s', i.e. inferred from the file name)")
219 add_argument_with_former_name(group, "--in-format", "--in_format", dest='in_format',
220 default=ArtifactFormat.AUTO, type=ArtifactFormat,
221 choices=list(ArtifactFormat),
222 help="How the input is encoded "
223 "(default is '%(default)s', i.e. inferred from the file name)")
224 add_argument_with_former_name(group, "--with-source-refs", "--with_source_refs", dest='with_source_refs',
225 action='store_true', default=False,
226 help="Record where each record came from while reading the "
227 "text format (default is %(default)s). Useful when an "
228 "INCLUDE tree has been flattened; diagnostics carry "
229 "positions either way.")
231 @staticmethod
232 def _add_output_arguments(parser: argparse.ArgumentParser,
233 required: bool = True) -> None:
234 group = parser.add_argument_group("Output")
235 add_argument_with_former_name(group, "--out-file", "--output", "-o", dest='output_path',
236 required=required, metavar='PATH',
237 help="Output path ('-' for stdout)")
238 add_argument_with_former_name(group, "--out-kind", "--out_kind", dest='out_kind',
239 default=ArtifactKind.AUTO, type=ArtifactKind,
240 choices=list(ArtifactKind),
241 help="Which message to write "
242 "(default is '%(default)s', i.e. inferred from the file name)")
243 add_argument_with_former_name(group, "--out-format", "--out_format", dest='out_format',
244 default=ArtifactFormat.AUTO, type=ArtifactFormat,
245 choices=list(ArtifactFormat),
246 help="How to encode the output "
247 "(default is '%(default)s', i.e. inferred from the file name)")
248 group.add_argument("--comments", dest='comments',
249 action='store_true', default=False,
250 help="Include the syntax hints from the specification as "
251 "comments (default is %(default)s). Text format only.")
253 @staticmethod
254 def _add_diagnostics_arguments(parser: argparse.ArgumentParser) -> None:
255 group = parser.add_argument_group(
256 "Diagnostics",
257 description="'json' and 'pb' emit stable PEX25D-Ennnn codes and are the "
258 "supported way to check a PEX25D writer in CI; 'human' output "
259 "is for reading, and its wording is not stable."
260 )
261 group.add_argument("--diagnostics", dest='diagnostics_format',
262 default=DiagnosticsFormat.DEFAULT, type=DiagnosticsFormat,
263 choices=list(DiagnosticsFormat),
264 help=render_enum_help(topic='diagnostics', enum_cls=DiagnosticsFormat))
265 add_argument_with_former_name(group, "--out-diagnostics", "--diagnostics_out", dest='diagnostics_path',
266 default=None, metavar='PATH',
267 help="Write diagnostics here instead of to the console "
268 "('-' for stdout)")
269 group.add_argument("--strict", dest='strict',
270 action='store_true', default=False,
271 help="Also run the geometric tier: ring and box "
272 "wellformedness, hole containment, conductor "
273 "overlap (default is %(default)s)")
274 group.add_argument("--werror", dest='warnings_are_errors',
275 action='store_true', default=False,
276 help="Treat warnings as errors for the exit code "
277 "(default is %(default)s)")
279 @staticmethod
280 def _add_plugin_option_argument(parser: argparse.ArgumentParser) -> None:
281 parser.add_argument("--option", dest='option_args', action='append', default=[],
282 metavar='NAME=VALUE',
283 help="Plugin option, e.g. mesh_size_max_um=10; repeatable. "
284 "VALUE is read as JSON where it parses (numbers, true, "
285 "false), otherwise as text")
287 def parse_args(self, arg_list: List[str] = None) -> argparse.Namespace:
288 main_parser = argparse.ArgumentParser(
289 description=f"{PROGRAM_NAME}: PEX25D format tool for KLayout-PEX",
290 prog=PROGRAM_NAME,
291 add_help=False,
292 formatter_class=SectionedHelpFormatter,
293 epilog=_epilog(),
294 )
295 self._add_special_options(main_parser)
297 subparsers = main_parser.add_subparsers(dest="command", metavar='<subcommand>')
299 # ---------------------------------------------------------- validate
300 parser_validate = subparsers.add_parser(
301 "validate",
302 help="Check a PEX25D artifact against the reference validator",
303 description="Check a PEX25D artifact and report coded diagnostics. "
304 "Exits 1 if the file is invalid, 0 if it is not.",
305 add_help=False, formatter_class=RichHelpFormatter)
306 parser_validate.add_argument("--help", "-h", action='help',
307 help="show this help message and exit")
308 self._add_input_argument(parser_validate)
309 self._add_diagnostics_arguments(parser_validate)
311 # ----------------------------------------------------------- convert
312 parser_convert = subparsers.add_parser(
313 "convert",
314 help="Re-encode a PEX25D artifact (text / pb / textpb)",
315 description="Change how a PEX25D artifact is encoded, without changing "
316 "what it says. To turn a file into a scene use 'resolve'; to "
317 "leave PEX25D for a solver use 'export'.",
318 add_help=False, formatter_class=RichHelpFormatter)
319 parser_convert.add_argument("--help", "-h", action='help',
320 help="show this help message and exit")
321 self._add_input_argument(parser_convert)
322 self._add_output_arguments(parser_convert)
324 # ----------------------------------------------------------- resolve
325 parser_resolve = subparsers.add_parser(
326 "resolve",
327 help="Resolve a PEX25DFile into a PEX25DScene",
328 description="Resolve CONNECTS / BETWEEN / WRAPS, flatten wrap depth and "
329 "compute terminal intersections, producing the scene that "
330 "solver adapters consume.",
331 add_help=False, formatter_class=RichHelpFormatter)
332 parser_resolve.add_argument("--help", "-h", action='help',
333 help="show this help message and exit")
334 self._add_input_argument(parser_resolve)
335 self._add_output_arguments(parser_resolve)
336 self._add_diagnostics_arguments(parser_resolve)
338 # ------------------------------------------------------------ export
339 parser_export = subparsers.add_parser(
340 "export",
341 help="Export a PEX25D scene as a solver's native input files",
342 description="Export the scene to an engine's own input format. Does "
343 "not run the engine — that is 'kpex extract'.",
344 add_help=False, formatter_class=RichHelpFormatter)
345 parser_export.add_argument("--help", "-h", action='help',
346 help="show this help message and exit")
347 self._add_input_argument(parser_export)
348 parser_export.add_argument("--to", dest='exporter_name', required=True,
349 type=str, metavar='EXPORTER',
350 help="Exporter name or distribution:name; run "
351 "'pex25d exporters' to list available identifiers")
352 add_argument_with_former_name(parser_export, "--out-dir", "--out_dir", dest='output_dir_path', required=True,
353 help="Directory to export the solver input files into")
354 parser_export.add_argument("--prefix", dest='prefix', default='',
355 help="Prefix for the generated file names "
356 "(default is the target's own)")
357 add_argument_with_former_name(parser_export, "--field-margin", "--field_margin", dest='field_margin',
358 type=float, default=None, metavar='UM',
359 help="How far to draw the laterally unbounded "
360 "materials beyond the geometry, in µm. "
361 "Ignored when the scene carries a DOMAIN_BOX.")
362 add_argument_with_former_name(parser_export, "--delaunay-amax", "--delaunay_amax", dest='delaunay_amax',
363 type=float, default=None, metavar='AREA',
364 help="Maximum triangle area; 0 means unconstrained")
365 add_argument_with_former_name(parser_export, "--delaunay-b", "--delaunay_b", dest='delaunay_b',
366 type=float, default=None, metavar='B',
367 help="Minimum mesh angle as b = 2·sin(angle)")
368 parser_export.add_argument("--stl", dest='write_stl',
369 action='store_true', default=None,
370 help="FasterCap / FastCap2: also dump the generated solids "
371 "as STL ('--to stl' writes only those)")
372 add_argument_with_former_name(parser_export, "--geo-check", "--geo_check", dest='geometry_check',
373 action='store_true', default=None,
374 help="Validate the geometry before writing")
375 self._add_plugin_option_argument(parser_export)
376 self._add_diagnostics_arguments(parser_export)
378 # ------------------------------------------------------------ import
379 parser_import = subparsers.add_parser(
380 "import",
381 help="Translate another tool's scene description into a PEX25DFile",
382 description="Run an importer plugin on INPUT, validate the PEX25DFile it "
383 "produces and write it; the file is written even if it is invalid. "
384 "Run 'pex25d importers' to list the installed importers.",
385 add_help=False, formatter_class=RichHelpFormatter)
386 parser_import.add_argument("--help", "-h", action='help',
387 help="show this help message and exit")
388 parser_import.add_argument("source_path", type=str, metavar='INPUT',
389 help="Input in the importer's own format")
390 parser_import.add_argument("--from", dest='importer_name', required=True,
391 type=str, metavar='IMPORTER',
392 help="Importer name or distribution:name; run "
393 "'pex25d importers' to list available identifiers")
394 parser_import.add_argument("--supporting", dest='supporting_args', action='append',
395 default=[], metavar='NAME=PATH',
396 help="Named supporting input the importer asks for, "
397 "e.g. process_stack=stack.txt; repeatable")
398 self._add_plugin_option_argument(parser_import)
399 self._add_output_arguments(parser_import)
400 self._add_diagnostics_arguments(parser_import)
402 # --------------------------------------------------------- exporters
403 subparsers.add_parser(
404 "exporters",
405 help="List installed exporter identifiers without loading plugins",
406 description="List built-in and installed exporters as distribution:name. "
407 "Listing reads metadata only; it does not check dependencies.",
408 formatter_class=RichHelpFormatter)
410 # ----------------------------------------------------------- plugins
411 subparsers.add_parser(
412 "plugins",
413 help="Show installed importers and exporters with versions and descriptions",
414 description="Show importer and exporter plugins, built-ins included. Selector "
415 "is what --from / --to take; Description is the package summary. "
416 "Reads metadata only; it does not import plugins or check dependencies.",
417 formatter_class=RichHelpFormatter)
419 # --------------------------------------------------------- importers
420 subparsers.add_parser(
421 "importers",
422 help="List installed importer identifiers without loading plugins",
423 description="List installed importers as distribution:name. "
424 "Listing reads metadata only; it does not check dependencies.",
425 formatter_class=RichHelpFormatter)
427 # -------------------------------------------------------------- show
428 parser_show = subparsers.add_parser(
429 "show",
430 help="Summarize what is inside a PEX25D artifact",
431 description="Print a human-readable summary. For machine consumption use "
432 "'convert --out-format textpb' instead — this output is not stable.",
433 add_help=False, formatter_class=RichHelpFormatter)
434 parser_show.add_argument("--help", "-h", action='help',
435 help="show this help message and exit")
436 self._add_input_argument(parser_show)
437 parser_show.add_argument("--section", dest='sections', action='append',
438 default=None, metavar='NAME',
439 choices=['header', 'meta', 'layers', 'dielectrics',
440 'conductors', 'terminals', 'resistance',
441 'domain', 'all'],
442 help="Section to print; repeatable (default is 'all')")
444 add_help_subcommand(subparsers)
445 add_subcommand_sections(main_parser, subparsers, SUBCOMMAND_SECTIONS)
447 if arg_list is None:
448 arg_list = sys.argv[1:]
449 args = main_parser.parse_args(arg_list)
450 handle_help_subcommand(main_parser, args)
452 if args.command is None:
453 main_parser.print_help()
454 sys.exit(ExitCode.USAGE)
456 self.validate_args(args)
457 return args
459 # --------------------------------------------------------------- validation
461 @staticmethod
462 def validate_args(args: argparse.Namespace) -> None:
463 found_errors = False
465 # Input spec. A scene default for stdin would be wrong:
466 # the only thing you can pipe in without a file name to inspect is what another tool wrote,
467 # and the format's own serialization is the text file.
468 if hasattr(args, 'input_path'):
469 try:
470 args.input_spec = infer_artifact_spec(args.input_path,
471 kind=args.in_kind,
472 format=args.in_format)
473 except ArtifactNamingError as e:
474 error(str(e))
475 found_errors = True
477 if hasattr(args, 'output_path') and args.output_path is not None:
478 default_kind = ArtifactKind.SCENE if args.command == 'resolve' else ArtifactKind.FILE
479 default_format = ArtifactFormat.PB if default_kind == ArtifactKind.SCENE \
480 else ArtifactFormat.TEXT
481 try:
482 args.output_spec = infer_artifact_spec(args.output_path,
483 kind=args.out_kind,
484 format=args.out_format,
485 default_kind=default_kind,
486 default_format=default_format)
487 except ArtifactNamingError as e:
488 error(str(e))
489 found_errors = True
491 if args.command == 'resolve' and getattr(args, 'output_spec', None) is not None:
492 if args.output_spec.kind != ArtifactKind.SCENE:
493 error("'resolve' produces a PEX25DScene; the output path or --out-kind asks for a PEX25DFile.")
494 found_errors = True
496 if args.command == 'import' and getattr(args, 'output_spec', None) is not None:
497 if args.output_spec.kind != ArtifactKind.FILE:
498 error("'import' produces a PEX25DFile; the output path or --out-kind asks for a PEX25DScene.")
499 found_errors = True
501 # Importers get plain paths, so missing inputs are caught here, before a plugin loads.
502 if hasattr(args, 'source_path') and not os.path.exists(args.source_path):
503 error(f"Input not found: {args.source_path}")
504 found_errors = True
506 if hasattr(args, 'supporting_args'):
507 args.supporting_files = {}
508 for argument in args.supporting_args:
509 name, separator, path = argument.partition('=')
510 if not separator or not name or not path:
511 error(f"--supporting expects NAME=PATH, got '{argument}'")
512 found_errors = True
513 elif name in args.supporting_files:
514 error(f"--supporting names '{name}' more than once")
515 found_errors = True
516 else:
517 args.supporting_files[name] = path
518 if not os.path.exists(path):
519 error(f"Supporting input '{name}' not found: {path}")
520 found_errors = True
522 # The export flags are shorthands for options; each option may be given once.
523 if hasattr(args, 'option_args'):
524 args.plugin_options = {
525 name: getattr(args, dest) for name, dest in EXPORT_OPTION_FLAGS.items()
526 if getattr(args, dest, None) is not None
527 }
528 given_by_flag = set(args.plugin_options)
529 for argument in args.option_args:
530 name, separator, text = argument.partition('=')
531 if not separator or not name:
532 error(f"--option expects NAME=VALUE, got '{argument}'")
533 found_errors = True
534 elif name in given_by_flag:
535 error(f"Option '{name}' is given both by its own flag and by --option")
536 found_errors = True
537 elif name in args.plugin_options:
538 error(f"--option names '{name}' more than once")
539 found_errors = True
540 else:
541 args.plugin_options[name] = option_value(text)
543 if found_errors:
544 raise ArgumentValidationError("Argument validation failed")
546 # -------------------------------------------------------------------- verbs
548 @staticmethod
549 def _load(args: argparse.Namespace, report: DiagnosticsReport) -> Any:
550 from .codec import load_artifact
551 return load_artifact(args.input_spec, report=report)
553 def run_validate(self, args: argparse.Namespace, report: DiagnosticsReport) -> None:
554 from .validator import validate
555 message = self._load(args, report)
556 validate(message, report=report, strict=args.strict)
558 def run_convert(self, args: argparse.Namespace, report: DiagnosticsReport) -> None:
559 from .codec import load_artifact, save_artifact
561 if args.input_spec.kind != args.output_spec.kind:
562 raise ArgumentValidationError(
563 f"'convert' re-encodes, it does not transform: input is a "
564 f"{args.input_spec.kind.value}, output would be a "
565 f"{args.output_spec.kind.value}. Use 'resolve' to turn a file into a scene."
566 )
568 message = load_artifact(args.input_spec, report=report,
569 with_source_refs=args.with_source_refs)
570 save_artifact(message, args.output_spec, comments=args.comments)
571 if not args.output_spec.is_stdio:
572 info(f"Wrote {args.output_spec}")
574 def run_resolve(self, args: argparse.Namespace, report: DiagnosticsReport) -> None:
575 from .codec import load_artifact, save_artifact
576 from .resolver import resolve
578 message = load_artifact(args.input_spec, report=report,
579 with_source_refs=args.with_source_refs)
580 scene = resolve(message, report=report, strict=args.strict)
581 save_artifact(scene, args.output_spec, comments=args.comments)
582 if not args.output_spec.is_stdio:
583 info(f"Wrote {args.output_spec}")
585 def run_export(self, args: argparse.Namespace, report: DiagnosticsReport):
586 from .exporters import exporter_registry, export_with_backend
587 from .codec import load_artifact
588 from .resolver import resolve
590 registry = exporter_registry()
591 exporter = registry.load(args.exporter_name)
592 message = load_artifact(args.input_spec, report=report,
593 with_source_refs=args.with_source_refs)
594 if args.input_spec.kind == ArtifactKind.FILE:
595 info("Input is an unresolved PEX25DFile, resolving it first")
596 message = resolve(message, report=report, strict=args.strict)
598 written = export_with_backend(
599 exporter, message, target=args.exporter_name,
600 output_dir_path=args.output_dir_path, prefix=args.prefix,
601 options=args.plugin_options, registry=registry)
602 for path in written:
603 subproc(path)
604 info(f"Wrote {len(written)} file(s) with '{args.exporter_name}' to {args.output_dir_path}")
606 def run_import(self, args: argparse.Namespace,
607 report: DiagnosticsReport) -> Optional[Tuple[str, str]]:
608 from .codec import save_artifact
609 from .importers import import_file
610 from .validator import validate
612 message = import_file(args.source_path, args.importer_name,
613 supporting_files=args.supporting_files,
614 options=args.plugin_options, report=report)
615 validate(message, report=report, strict=args.strict)
616 save_artifact(message, args.output_spec, comments=args.comments)
617 if args.output_spec.is_stdio:
618 return None
619 info(f"Wrote {args.output_spec}")
620 if report.exit_code != ExitCode.OK:
621 return "The imported file is invalid; review it at:", os.path.abspath(args.output_spec.path)
622 return None
624 def run_show(self, args: argparse.Namespace, report: DiagnosticsReport) -> None:
625 from .codec import load_artifact
626 from .show import show
628 message = load_artifact(args.input_spec, report=report,
629 with_source_refs=args.with_source_refs)
630 show(message, kind=args.input_spec.kind, sections=args.sections or ['all'])
632 # --------------------------------------------------------------------- main
634 def main(self, argv: List[str]) -> None:
635 try:
636 args = self.parse_args(argv[1:])
637 except ArgumentValidationError:
638 sys.exit(ExitCode.USAGE)
640 set_log_level(args.log_level)
642 if args.command == 'plugins':
643 for table in plugin_tables():
644 console.print(table)
645 sys.exit(ExitCode.OK)
647 if args.command in ('exporters', 'importers'):
648 from .exporters import exporter_registry
649 from .importers import importer_registry
650 registry = exporter_registry() if args.command == 'exporters' else importer_registry()
651 for plugin in registry.plugins:
652 print(plugin.qualified_name)
653 sys.exit(ExitCode.OK)
655 # When the artifact goes to stdout, stdout belongs to the artifact and to
656 # nothing else — one stray log line and the consumer of the pipe is parsing garbage.
657 # Redirecting sys.stdout to stderr for the whole run is therefore necessary:
658 # the rich console resolves sys.stdout at write time, so every info()/rule()/warning() follows,
659 # including ones written by code that never considered being in a pipeline.
660 # The artifact I/O itself uses sys.__stdout__ and is unaffected.
661 output_spec: Optional[ArtifactSpec] = getattr(args, 'output_spec', None)
662 artifact_on_stdout = output_spec is not None and output_spec.is_stdio
664 with contextlib.ExitStack() as stack:
665 if artifact_on_stdout:
666 stack.enter_context(contextlib.redirect_stdout(sys.stderr))
667 warn_deprecated_options(args)
668 self._run(args)
670 def _run(self, args: argparse.Namespace) -> None:
671 input_spec: Optional[ArtifactSpec] = getattr(args, 'input_spec', None)
672 if input_spec is None or input_spec.path != STDIO_PATH:
673 rule('Command line arguments')
674 subproc(' '.join(map(shlex.quote, sys.argv)))
676 report = DiagnosticsReport(
677 warnings_are_errors=getattr(args, 'warnings_are_errors', False))
679 handler = {
680 'validate': self.run_validate,
681 'convert': self.run_convert,
682 'resolve': self.run_resolve,
683 'export': self.run_export,
684 'import': self.run_import,
685 'show': self.run_show,
686 }[args.command]
688 try:
689 note = handler(args, report)
690 except NotImplementedError as e:
691 error(str(e))
692 sys.exit(ExitCode.NOT_IMPLEMENTED)
693 except ArgumentValidationError as e:
694 error(str(e))
695 sys.exit(ExitCode.USAGE)
696 except (ExporterExecutionError, ImporterExecutionError) as e:
697 error(str(e))
698 debug("Plugin traceback", exc_info=True)
699 self._emit_diagnostics(args, report)
700 sys.exit(ExitCode.INTERNAL_ERROR)
701 except (ExportError, ImporterError) as e:
702 error(str(e))
703 self._emit_diagnostics(args, report)
704 sys.exit(ExitCode.USAGE)
705 except (ReadError, ResolveError) as e:
706 error(str(e))
707 self._emit_diagnostics(args, report)
708 sys.exit(ExitCode.DIAGNOSTIC_ERRORS)
709 except ProtobufNotGeneratedError as e:
710 error(str(e))
711 sys.exit(ExitCode.USAGE)
712 except (OSError, ValueError) as e:
713 error(f"Failed to process {input_spec or args.source_path}: {e}")
714 sys.exit(ExitCode.USAGE)
716 self._emit_diagnostics(args, report)
717 if note: # after the diagnostics, so it stays next to the prompt
718 message, path = note
719 warning(message)
720 console.print(path, soft_wrap=True, markup=False, highlight=False) # unwrapped, to copy
721 sys.exit(report.exit_code)
723 @staticmethod
724 def _emit_diagnostics(args: argparse.Namespace,
725 report: DiagnosticsReport) -> None:
726 diagnostics_format: DiagnosticsFormat = getattr(
727 args, 'diagnostics_format', DiagnosticsFormat.HUMAN)
729 output_spec: Optional[ArtifactSpec] = getattr(args, 'output_spec', None)
730 artifact_on_stdout = output_spec is not None and output_spec.is_stdio
732 diagnostics_path: Optional[str] = getattr(args, 'diagnostics_path', None)
733 if diagnostics_path is not None and diagnostics_path != STDIO_PATH:
734 binary = diagnostics_format == DiagnosticsFormat.PB
735 with open(diagnostics_path, 'wb' if binary else 'w', encoding=None if binary else 'utf-8') as f:
736 report.render(diagnostics_format, stream=f)
737 return
739 if diagnostics_format == DiagnosticsFormat.HUMAN:
740 # 'validate' answers with its diagnostics, so an explicit "none" is the
741 # result. For the other verbs a clean run should simply be quiet.
742 if report.diagnostics or args.command == 'validate':
743 report.render(diagnostics_format)
744 return
746 with diagnostics_stream(writes_to_stdout=artifact_on_stdout) as stream:
747 report.render(diagnostics_format, stream=stream)