| #!/usr/bin/env python3 |
| # Licensed under the Apache-2.0 license |
| # SPDX-License-Identifier: Apache-2.0 |
| |
| """SGPIOM JSON tooling for validate/merge/generate/check/report workflows.""" |
| |
| from __future__ import annotations |
| |
| import argparse |
| import copy |
| import hashlib |
| import json |
| import sys |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any, Dict, Iterable, List, Tuple |
| |
| JsonMap = Dict[str, Any] |
| |
| |
| class ValidationError(Exception): |
| """Raised when manifest validation fails.""" |
| |
| |
| @dataclass |
| class ValidationResult: |
| errors: List[str] |
| |
| @property |
| def ok(self) -> bool: |
| return not self.errors |
| |
| |
| def load_json(path: Path) -> JsonMap: |
| try: |
| data = json.loads(path.read_text(encoding="utf-8")) |
| except FileNotFoundError as exc: |
| raise ValidationError(f"input not found: {path}") from exc |
| except json.JSONDecodeError as exc: |
| raise ValidationError(f"invalid JSON in {path}: {exc}") from exc |
| |
| if not isinstance(data, dict): |
| raise ValidationError(f"top-level JSON must be an object: {path}") |
| return data |
| |
| |
| def deep_merge(a: JsonMap, b: JsonMap) -> JsonMap: |
| """Merge b into a recursively for plain objects (non-list values).""" |
| out = copy.deepcopy(a) |
| for key, b_val in b.items(): |
| a_val = out.get(key) |
| if isinstance(a_val, dict) and isinstance(b_val, dict): |
| out[key] = deep_merge(a_val, b_val) |
| else: |
| out[key] = copy.deepcopy(b_val) |
| return out |
| |
| |
| def merge_named_list( |
| base: List[JsonMap], override: List[JsonMap], key_name: str |
| ) -> List[JsonMap]: |
| """Deterministically merge list entries by key, preserving base order first.""" |
| result: List[JsonMap] = [] |
| index: Dict[str, int] = {} |
| |
| for item in base: |
| name = item.get(key_name) |
| if isinstance(name, str) and name not in index: |
| index[name] = len(result) |
| result.append(copy.deepcopy(item)) |
| |
| for item in override: |
| name = item.get(key_name) |
| if not isinstance(name, str): |
| # Validation handles this; keep deterministic behavior by append. |
| result.append(copy.deepcopy(item)) |
| continue |
| if name in index: |
| old_item = result[index[name]] |
| if isinstance(old_item, dict) and isinstance(item, dict): |
| result[index[name]] = deep_merge(old_item, item) |
| else: |
| result[index[name]] = copy.deepcopy(item) |
| else: |
| index[name] = len(result) |
| result.append(copy.deepcopy(item)) |
| |
| return result |
| |
| |
| def merge_manifests(inputs: Iterable[JsonMap]) -> JsonMap: |
| merged: JsonMap = {} |
| for incoming in inputs: |
| previous = copy.deepcopy(merged) |
| merged = deep_merge(merged, incoming) |
| |
| prev_banks = previous.get("banks") |
| in_banks = incoming.get("banks") |
| if isinstance(prev_banks, list) and isinstance(in_banks, list): |
| merged["banks"] = merge_named_list(prev_banks, in_banks, "name") |
| |
| prev_signals = previous.get("signals") |
| in_signals = incoming.get("signals") |
| if isinstance(prev_signals, list) and isinstance(in_signals, list): |
| merged["signals"] = merge_named_list( |
| prev_signals, in_signals, "logical_name" |
| ) |
| |
| return merged |
| |
| |
| def _is_nonneg_int(value: Any) -> bool: |
| # `bool` is a subclass of `int`; exclude it so `true`/`false` are rejected. |
| return type(value) is int and value >= 0 |
| |
| |
| def _is_bool(value: Any) -> bool: |
| return isinstance(value, bool) |
| |
| |
| def _is_hex_string(value: Any) -> bool: |
| # Lowercase `0x` prefix only, to match the schema pattern and keep a single |
| # canonical form across schema-only and tool validation. |
| if not isinstance(value, str) or not value.startswith("0x"): |
| return False |
| try: |
| int(value, 16) |
| return True |
| except ValueError: |
| return False |
| |
| |
| # Largest value representable by the generated `u32` fields. |
| _U32_MAX = 0xFFFF_FFFF |
| |
| |
| def _hex_or_int_value(value: Any) -> int: |
| """Numeric value of a hex-string or integer (callers pre-validate the type).""" |
| return int(value, 16) if isinstance(value, str) else int(value) |
| |
| |
| def _rust_str_literal(value: str) -> str: |
| """Emit a `value` as an escaped Rust string literal (incl. surrounding quotes).""" |
| out = [] |
| for ch in value: |
| if ch == "\\": |
| out.append("\\\\") |
| elif ch == '"': |
| out.append('\\"') |
| elif ch == "\n": |
| out.append("\\n") |
| elif ch == "\r": |
| out.append("\\r") |
| elif ch == "\t": |
| out.append("\\t") |
| else: |
| out.append(ch) |
| return '"' + "".join(out) + '"' |
| |
| |
| def validate_manifest(manifest: JsonMap) -> ValidationResult: |
| errors: List[str] = [] |
| |
| board = manifest.get("board") |
| if not isinstance(board, str) or not board: |
| errors.append("board: required non-empty string") |
| |
| controller = manifest.get("controller") |
| if not isinstance(controller, dict): |
| errors.append("controller: required object") |
| return ValidationResult(errors) |
| |
| required_controller = [ |
| "name", |
| "base_addr", |
| "bus_frequency_hz", |
| "ngpios", |
| "enabled", |
| ] |
| for key in required_controller: |
| if key not in controller: |
| errors.append(f"controller.{key}: required") |
| |
| if "name" in controller and not isinstance(controller["name"], str): |
| errors.append("controller.name: must be string") |
| |
| if "base_addr" in controller: |
| base_addr = controller["base_addr"] |
| if not (_is_hex_string(base_addr) or _is_nonneg_int(base_addr)): |
| errors.append("controller.base_addr: must be hex string or integer") |
| elif _hex_or_int_value(base_addr) > _U32_MAX: |
| errors.append("controller.base_addr: exceeds u32 range (> 0xffffffff)") |
| |
| if "bus_frequency_hz" in controller: |
| bus_freq = controller["bus_frequency_hz"] |
| if not _is_nonneg_int(bus_freq): |
| errors.append("controller.bus_frequency_hz: must be non-negative integer") |
| elif bus_freq > _U32_MAX: |
| errors.append( |
| "controller.bus_frequency_hz: exceeds u32 range (> 0xffffffff)" |
| ) |
| |
| if "ngpios" in controller and ( |
| not _is_nonneg_int(controller["ngpios"]) |
| or controller["ngpios"] == 0 |
| or controller["ngpios"] > 128 |
| ): |
| # Four 32-pin banks (A-P) => 128 max (matches Zephyr AST10x0 DTS). |
| errors.append("controller.ngpios: must be a positive integer <= 128") |
| |
| if "enabled" in controller and not _is_bool(controller["enabled"]): |
| errors.append("controller.enabled: must be bool") |
| |
| banks = manifest.get("banks") |
| if not isinstance(banks, list) or not banks: |
| errors.append("banks: required non-empty list") |
| return ValidationResult(errors) |
| |
| bank_by_name: Dict[str, JsonMap] = {} |
| seen_bank_names: set[str] = set() |
| seen_pin_offsets: set[int] = set() |
| total_bank_gpios = 0 |
| controller_ngpios = controller.get("ngpios") |
| |
| for i, bank in enumerate(banks): |
| prefix = f"banks[{i}]" |
| if not isinstance(bank, dict): |
| errors.append(f"{prefix}: must be object") |
| continue |
| |
| for field in ["name", "pin_offset", "ngpios", "reserved_pins"]: |
| if field not in bank: |
| errors.append(f"{prefix}.{field}: required") |
| |
| name = bank.get("name") |
| if not isinstance(name, str) or not name: |
| errors.append(f"{prefix}.name: must be non-empty string") |
| else: |
| if name in seen_bank_names: |
| errors.append(f"{prefix}.name: duplicate bank name '{name}'") |
| seen_bank_names.add(name) |
| bank_by_name[name] = bank |
| |
| pin_offset = bank.get("pin_offset") |
| if not _is_nonneg_int(pin_offset): |
| errors.append(f"{prefix}.pin_offset: must be non-negative integer") |
| elif pin_offset not in (0, 32, 64, 96): |
| # Runtime derives the bank as pin_offset >> 5; only the four 32-pin |
| # bank boundaries are valid (Zephyr offsets 0/32/64/96). |
| errors.append( |
| f"{prefix}.pin_offset: must be one of 0, 32, 64, 96 (got {pin_offset})" |
| ) |
| else: |
| if pin_offset in seen_pin_offsets: |
| errors.append(f"{prefix}.pin_offset: duplicate pin_offset {pin_offset}") |
| seen_pin_offsets.add(pin_offset) |
| |
| ngpios = bank.get("ngpios") |
| if not _is_nonneg_int(ngpios) or ngpios == 0 or ngpios > 32: |
| errors.append(f"{prefix}.ngpios: must be integer in range 1..32") |
| else: |
| total_bank_gpios += ngpios |
| if ( |
| _is_nonneg_int(pin_offset) |
| and isinstance(controller_ngpios, int) |
| and pin_offset + ngpios > controller_ngpios |
| ): |
| errors.append( |
| f"{prefix}: pin_offset + ngpios ({pin_offset} + {ngpios}) " |
| f"exceeds controller.ngpios ({controller_ngpios})" |
| ) |
| |
| reserved = bank.get("reserved_pins") |
| if not isinstance(reserved, list): |
| errors.append(f"{prefix}.reserved_pins: must be list") |
| else: |
| for j, pin in enumerate(reserved): |
| if not _is_nonneg_int(pin): |
| errors.append( |
| f"{prefix}.reserved_pins[{j}]: must be non-negative integer" |
| ) |
| elif isinstance(ngpios, int) and pin >= ngpios: |
| errors.append( |
| f"{prefix}.reserved_pins[{j}]: pin {pin} out of range for ngpios {ngpios}" |
| ) |
| |
| if isinstance(controller_ngpios, int) and total_bank_gpios > controller_ngpios: |
| errors.append( |
| "controller.ngpios: smaller than sum of bank ngpios " |
| f"({controller_ngpios} < {total_bank_gpios})" |
| ) |
| |
| signals = manifest.get("signals") |
| if not isinstance(signals, list): |
| errors.append("signals: required list") |
| return ValidationResult(errors) |
| |
| seen_signal_names: set[str] = set() |
| seen_pin_ownership: set[Tuple[str, int]] = set() |
| |
| for i, signal in enumerate(signals): |
| prefix = f"signals[{i}]" |
| if not isinstance(signal, dict): |
| errors.append(f"{prefix}: must be object") |
| continue |
| |
| for field in [ |
| "logical_name", |
| "bank", |
| "pin", |
| "direction", |
| "active_level", |
| "safe_default", |
| ]: |
| if field not in signal: |
| errors.append(f"{prefix}.{field}: required") |
| |
| logical_name = signal.get("logical_name") |
| if not isinstance(logical_name, str) or not logical_name: |
| errors.append(f"{prefix}.logical_name: must be non-empty string") |
| elif logical_name in seen_signal_names: |
| errors.append( |
| f"{prefix}.logical_name: duplicate logical name '{logical_name}'" |
| ) |
| else: |
| seen_signal_names.add(logical_name) |
| |
| bank_name = signal.get("bank") |
| if not isinstance(bank_name, str) or not bank_name: |
| errors.append(f"{prefix}.bank: must be non-empty string") |
| continue |
| |
| pin = signal.get("pin") |
| if not _is_nonneg_int(pin): |
| errors.append(f"{prefix}.pin: must be non-negative integer") |
| continue |
| |
| direction = signal.get("direction") |
| if direction not in ("in", "out"): |
| errors.append(f"{prefix}.direction: must be 'in' or 'out'") |
| |
| active_level = signal.get("active_level") |
| if active_level not in ("high", "low"): |
| errors.append(f"{prefix}.active_level: must be 'high' or 'low'") |
| |
| safe_default = signal.get("safe_default") |
| # `bool` is an `int` subclass and true==1/false==0, so exclude it explicitly. |
| if not ( |
| safe_default is None |
| or (type(safe_default) is int and safe_default in (0, 1)) |
| ): |
| errors.append(f"{prefix}.safe_default: must be null, 0, or 1") |
| |
| bank = bank_by_name.get(bank_name) |
| if bank is None: |
| errors.append(f"{prefix}.bank: unknown bank '{bank_name}'") |
| continue |
| |
| bank_ngpios = bank.get("ngpios") |
| if isinstance(bank_ngpios, int) and pin >= bank_ngpios: |
| errors.append( |
| f"{prefix}.pin: pin {pin} out of range for bank '{bank_name}' ngpios {bank_ngpios}" |
| ) |
| |
| reserved_pins = bank.get("reserved_pins", []) |
| if isinstance(reserved_pins, list) and pin in reserved_pins: |
| errors.append(f"{prefix}.pin: pin {pin} is reserved in bank '{bank_name}'") |
| |
| owner_key = (bank_name, pin) |
| if owner_key in seen_pin_ownership: |
| errors.append( |
| f"{prefix}: overlaps existing signal ownership at {bank_name}[{pin}]" |
| ) |
| else: |
| seen_pin_ownership.add(owner_key) |
| |
| if direction == "in" and safe_default is not None: |
| errors.append(f"{prefix}.safe_default: input signal must use null") |
| |
| return ValidationResult(errors) |
| |
| |
| def canonical_json(data: JsonMap) -> str: |
| return json.dumps(data, sort_keys=True, separators=(",", ":")) |
| |
| |
| def rust_ident(name: str) -> str: |
| out = [] |
| for ch in name: |
| if ch.isalnum(): |
| out.append(ch.upper()) |
| else: |
| out.append("_") |
| ident = "".join(out).strip("_") |
| if not ident: |
| ident = "UNNAMED" |
| if ident[0].isdigit(): |
| ident = f"_{ident}" |
| return ident |
| |
| |
| def render_rust(manifest: JsonMap) -> str: |
| manifest_hash = hashlib.sha256(canonical_json(manifest).encode("utf-8")).hexdigest() |
| |
| controller = manifest["controller"] |
| banks = sorted(manifest["banks"], key=lambda b: (b["pin_offset"], b["name"])) |
| signals = sorted(manifest["signals"], key=lambda s: s["logical_name"]) |
| |
| lines: List[str] = [] |
| lines.append("// Licensed under the Apache-2.0 license") |
| lines.append("// SPDX-License-Identifier: Apache-2.0") |
| lines.append("") |
| lines.append("// @generated by tools/sgpiom/sgpio_json_tool.py; DO NOT EDIT.") |
| lines.append(f'pub const SGPIOM_MANIFEST_HASH: &str = "{manifest_hash}";') |
| lines.append("") |
| lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]") |
| lines.append("pub enum Direction { In, Out }") |
| lines.append("") |
| lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]") |
| lines.append("pub enum ActiveLevel { High, Low }") |
| lines.append("") |
| lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]") |
| lines.append("pub struct SgpiomControllerConfig {") |
| lines.append(" pub name: &'static str,") |
| lines.append(" pub base_addr: u32,") |
| lines.append(" pub bus_frequency_hz: u32,") |
| lines.append(" pub ngpios: u16,") |
| lines.append(" pub enabled: bool,") |
| lines.append("}") |
| lines.append("") |
| lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]") |
| lines.append("pub struct SgpiomBankConfig {") |
| lines.append(" pub name: &'static str,") |
| lines.append(" pub pin_offset: u8,") |
| lines.append(" pub ngpios: u8,") |
| lines.append(" pub reserved_pins: &'static [u8],") |
| lines.append("}") |
| lines.append("") |
| lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]") |
| lines.append("pub struct SgpiomSignalConfig {") |
| lines.append(" pub logical_name: &'static str,") |
| lines.append(" pub bank: &'static str,") |
| lines.append(" pub pin: u8,") |
| lines.append(" pub direction: Direction,") |
| lines.append(" pub active_level: ActiveLevel,") |
| lines.append(" pub safe_default: Option<bool>,") |
| lines.append("}") |
| lines.append("") |
| |
| # Canonicalize to a lowercase `0x` Rust literal regardless of input form. |
| base_addr_literal = hex(_hex_or_int_value(controller["base_addr"])) |
| |
| lines.append( |
| "pub const SGPIOM_CONTROLLER: SgpiomControllerConfig = SgpiomControllerConfig {" |
| ) |
| lines.append(f" name: {_rust_str_literal(controller['name'])},") |
| lines.append(f" base_addr: {base_addr_literal},") |
| lines.append(f" bus_frequency_hz: {controller['bus_frequency_hz']},") |
| lines.append(f" ngpios: {controller['ngpios']},") |
| lines.append(f" enabled: {'true' if controller['enabled'] else 'false'},") |
| lines.append("};") |
| lines.append("") |
| |
| # Index the const name (not rust_ident(name)) so distinct bank names that |
| # sanitize to the same identifier (e.g. "a-b" / "a_b") cannot collide into |
| # one RESERVED_PINS_* symbol. |
| for i, bank in enumerate(banks): |
| reserved = ", ".join(str(pin) for pin in sorted(bank.get("reserved_pins", []))) |
| lines.append( |
| f"const RESERVED_PINS_{i}: [u8; {len(bank.get('reserved_pins', []))}] = [{reserved}];" |
| ) |
| lines.append("") |
| |
| lines.append(f"pub const SGPIOM_BANKS: [SgpiomBankConfig; {len(banks)}] = [") |
| for i, bank in enumerate(banks): |
| lines.append(" SgpiomBankConfig {") |
| lines.append(f" name: {_rust_str_literal(bank['name'])},") |
| lines.append(f" pin_offset: {bank['pin_offset']},") |
| lines.append(f" ngpios: {bank['ngpios']},") |
| lines.append(f" reserved_pins: &RESERVED_PINS_{i},") |
| lines.append(" },") |
| lines.append("];\n") |
| |
| lines.append(f"pub const SGPIOM_SIGNALS: [SgpiomSignalConfig; {len(signals)}] = [") |
| for signal in signals: |
| dir_value = "Direction::In" if signal["direction"] == "in" else "Direction::Out" |
| lvl_value = ( |
| "ActiveLevel::High" |
| if signal["active_level"] == "high" |
| else "ActiveLevel::Low" |
| ) |
| safe_default = signal["safe_default"] |
| if safe_default is None: |
| safe_default_text = "None" |
| else: |
| safe_default_text = "Some(true)" if safe_default == 1 else "Some(false)" |
| |
| lines.append(" SgpiomSignalConfig {") |
| lines.append( |
| f" logical_name: {_rust_str_literal(signal['logical_name'])}," |
| ) |
| lines.append(f" bank: {_rust_str_literal(signal['bank'])},") |
| lines.append(f" pin: {signal['pin']},") |
| lines.append(f" direction: {dir_value},") |
| lines.append(f" active_level: {lvl_value},") |
| lines.append(f" safe_default: {safe_default_text},") |
| lines.append(" },") |
| lines.append("];\n") |
| |
| return "\n".join(lines) |
| |
| |
| def load_and_merge(input_paths: List[Path]) -> JsonMap: |
| if not input_paths: |
| raise ValidationError("at least one --input is required") |
| manifests = [load_json(path) for path in input_paths] |
| return merge_manifests(manifests) |
| |
| |
| def cmd_validate(args: argparse.Namespace) -> int: |
| merged = load_and_merge(args.input) |
| result = validate_manifest(merged) |
| if not result.ok: |
| for err in result.errors: |
| print(f"ERROR: {err}", file=sys.stderr) |
| return 1 |
| |
| if args.merged_out: |
| args.merged_out.write_text( |
| json.dumps(merged, indent=2) + "\n", encoding="utf-8" |
| ) |
| |
| print("validate: OK") |
| return 0 |
| |
| |
| def cmd_merge(args: argparse.Namespace) -> int: |
| merged = load_and_merge(args.input) |
| result = validate_manifest(merged) |
| if not result.ok: |
| for err in result.errors: |
| print(f"ERROR: {err}", file=sys.stderr) |
| return 1 |
| |
| args.output.write_text(json.dumps(merged, indent=2) + "\n", encoding="utf-8") |
| print(f"merge: wrote {args.output}") |
| return 0 |
| |
| |
| def cmd_generate(args: argparse.Namespace) -> int: |
| merged = load_and_merge(args.input) |
| result = validate_manifest(merged) |
| if not result.ok: |
| for err in result.errors: |
| print(f"ERROR: {err}", file=sys.stderr) |
| return 1 |
| |
| rust_text = render_rust(merged) |
| args.output.write_text(rust_text, encoding="utf-8") |
| print(f"generate: wrote {args.output}") |
| return 0 |
| |
| |
| def cmd_check(args: argparse.Namespace) -> int: |
| merged = load_and_merge(args.input) |
| result = validate_manifest(merged) |
| if not result.ok: |
| for err in result.errors: |
| print(f"ERROR: {err}", file=sys.stderr) |
| return 1 |
| |
| expected = render_rust(merged) |
| try: |
| actual = args.output.read_text(encoding="utf-8") |
| except FileNotFoundError: |
| print(f"ERROR: output file missing: {args.output}", file=sys.stderr) |
| return 1 |
| |
| if actual != expected: |
| print("ERROR: generated output is out of date", file=sys.stderr) |
| return 1 |
| |
| print("check: OK") |
| return 0 |
| |
| |
| def cmd_report(args: argparse.Namespace) -> int: |
| merged = load_and_merge(args.input) |
| result = validate_manifest(merged) |
| if not result.ok: |
| for err in result.errors: |
| print(f"ERROR: {err}", file=sys.stderr) |
| return 1 |
| |
| controller = merged["controller"] |
| banks = sorted(merged["banks"], key=lambda b: (b["pin_offset"], b["name"])) |
| signals = merged["signals"] |
| |
| bank_counts: Dict[str, int] = {bank["name"]: 0 for bank in banks} |
| out_count = 0 |
| in_count = 0 |
| for signal in signals: |
| bank_counts[signal["bank"]] = bank_counts.get(signal["bank"], 0) + 1 |
| if signal["direction"] == "out": |
| out_count += 1 |
| else: |
| in_count += 1 |
| |
| print(f"board: {merged['board']}") |
| print( |
| "controller: " |
| f"{controller['name']} base={controller['base_addr']} " |
| f"freq={controller['bus_frequency_hz']}Hz ngpios={controller['ngpios']} " |
| f"enabled={controller['enabled']}" |
| ) |
| print( |
| f"banks: {len(banks)} signals: {len(signals)} (in={in_count}, out={out_count})" |
| ) |
| print("bank usage:") |
| for bank in banks: |
| name = bank["name"] |
| print( |
| f" - {name}: offset={bank['pin_offset']} ngpios={bank['ngpios']} " |
| f"reserved={len(bank['reserved_pins'])} signals={bank_counts.get(name, 0)}" |
| ) |
| |
| return 0 |
| |
| |
| def build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser(description="SGPIOM JSON tooling") |
| subparsers = parser.add_subparsers(dest="command", required=True) |
| |
| def add_inputs(sub: argparse.ArgumentParser) -> None: |
| sub.add_argument( |
| "--input", |
| type=Path, |
| action="append", |
| required=True, |
| help="Input manifest path (repeatable, merge order is declaration order)", |
| ) |
| |
| validate_parser = subparsers.add_parser("validate", help="Validate merged manifest") |
| add_inputs(validate_parser) |
| validate_parser.add_argument("--merged-out", type=Path) |
| validate_parser.set_defaults(func=cmd_validate) |
| |
| merge_parser = subparsers.add_parser("merge", help="Merge manifests and emit JSON") |
| add_inputs(merge_parser) |
| merge_parser.add_argument("--output", type=Path, required=True) |
| merge_parser.set_defaults(func=cmd_merge) |
| |
| generate_parser = subparsers.add_parser( |
| "generate", help="Generate Rust static configuration module" |
| ) |
| add_inputs(generate_parser) |
| generate_parser.add_argument("--output", type=Path, required=True) |
| generate_parser.set_defaults(func=cmd_generate) |
| |
| check_parser = subparsers.add_parser( |
| "check", help="Verify generated Rust output is up to date" |
| ) |
| add_inputs(check_parser) |
| check_parser.add_argument("--output", type=Path, required=True) |
| check_parser.set_defaults(func=cmd_check) |
| |
| report_parser = subparsers.add_parser( |
| "report", help="Print manifest summary report" |
| ) |
| add_inputs(report_parser) |
| report_parser.set_defaults(func=cmd_report) |
| |
| return parser |
| |
| |
| def main(argv: List[str]) -> int: |
| parser = build_parser() |
| args = parser.parse_args(argv) |
| try: |
| return args.func(args) |
| except ValidationError as exc: |
| print(f"ERROR: {exc}", file=sys.stderr) |
| return 1 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main(sys.argv[1:])) |