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

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 - 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. 

36 

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. 

39 

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

41""" 

42 

43from __future__ import annotations 

44 

45import argparse 

46import contextlib 

47import json 

48import os 

49import shlex 

50import sys 

51from typing import * 

52 

53import rich.console 

54import rich.markdown 

55import rich.table 

56import rich.text 

57from rich_argparse import RichHelpFormatter 

58 

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__ 

81 

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) 

101 

102 

103PROGRAM_NAME = "pex25d" 

104 

105 

106class ArgumentValidationError(Exception): 

107 pass 

108 

109 

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 | 

118 

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. 

122 

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

124`--in-kind` / `--in-format` / `--out-kind` / `--out-format`: 

125 

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 | 

133 

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""" 

138 

139 

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.""" 

148 

149 

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.""" 

158 

159 

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 

166 

167 

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 ) 

173 

174 

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 

179 

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 

195 

196 

197class Pex25DCLI: 

198 # ------------------------------------------------------------------ parsing 

199 

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) 

208 

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.") 

230 

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.") 

252 

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)") 

278 

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") 

286 

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) 

296 

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

298 

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) 

310 

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) 

323 

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) 

337 

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) 

377 

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) 

401 

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) 

409 

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) 

418 

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) 

426 

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')") 

443 

444 add_help_subcommand(subparsers) 

445 add_subcommand_sections(main_parser, subparsers, SUBCOMMAND_SECTIONS) 

446 

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) 

451 

452 if args.command is None: 

453 main_parser.print_help() 

454 sys.exit(ExitCode.USAGE) 

455 

456 self.validate_args(args) 

457 return args 

458 

459 # --------------------------------------------------------------- validation 

460 

461 @staticmethod 

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

463 found_errors = False 

464 

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 

476 

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 

490 

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 

495 

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 

500 

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 

505 

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 

521 

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) 

542 

543 if found_errors: 

544 raise ArgumentValidationError("Argument validation failed") 

545 

546 # -------------------------------------------------------------------- verbs 

547 

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) 

552 

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) 

557 

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

559 from .codec import load_artifact, save_artifact 

560 

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 ) 

567 

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}") 

573 

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

575 from .codec import load_artifact, save_artifact 

576 from .resolver import resolve 

577 

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}") 

584 

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 

589 

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) 

597 

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}") 

605 

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 

611 

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 

623 

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

625 from .codec import load_artifact 

626 from .show import show 

627 

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']) 

631 

632 # --------------------------------------------------------------------- main 

633 

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) 

639 

640 set_log_level(args.log_level) 

641 

642 if args.command == 'plugins': 

643 for table in plugin_tables(): 

644 console.print(table) 

645 sys.exit(ExitCode.OK) 

646 

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) 

654 

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 

663 

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) 

669 

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))) 

675 

676 report = DiagnosticsReport( 

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

678 

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] 

687 

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) 

715 

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) 

722 

723 @staticmethod 

724 def _emit_diagnostics(args: argparse.Namespace, 

725 report: DiagnosticsReport) -> None: 

726 diagnostics_format: DiagnosticsFormat = getattr( 

727 args, 'diagnostics_format', DiagnosticsFormat.HUMAN) 

728 

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

730 artifact_on_stdout = output_spec is not None and output_spec.is_stdio 

731 

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 

738 

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 

745 

746 with diagnostics_stream(writes_to_stdout=artifact_on_stdout) as stream: 

747 report.render(diagnostics_format, stream=stream)