From f96c53add907be133da27c42d4313fbf0bcab756 Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sat, 4 Jul 2026 21:20:28 +0300 Subject: [PATCH] feat: add engine descriptor module (Meta/Arg/Flag/Opt/Rest, argparse) --- engine/descriptor.py | 77 ++++++++++++++++++++++++++++++++++++++++ tests/test_descriptor.py | 39 ++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 engine/descriptor.py create mode 100644 tests/test_descriptor.py diff --git a/engine/descriptor.py b/engine/descriptor.py new file mode 100644 index 0000000..599a4e5 --- /dev/null +++ b/engine/descriptor.py @@ -0,0 +1,77 @@ +# engine/descriptor.py +"""Declarative action descriptors — successor to the bash action_metadata() heredoc. + +Meta drives BOTH the help text and argparse parsing. Exit code 64 (EX_USAGE) on a +usage error, matching the bash engine. +""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass, field +from typing import List + + +class _Parser(argparse.ArgumentParser): + """ArgumentParser that exits 64 (EX_USAGE) instead of 2 on a usage error.""" + + def error(self, message: str): + self.print_usage() + self.exit(64, f"{self.prog}: error: {message}\n") + + +@dataclass +class Arg: + """A positional argument. required=False -> optional positional (default None).""" + name: str + help: str = "" + required: bool = True + + def add_to(self, p: argparse.ArgumentParser) -> None: + if self.required: + p.add_argument(self.name, help=self.help) + else: + p.add_argument(self.name, nargs="?", default=None, help=self.help) + + +@dataclass +class Opt: + """An optional value flag, e.g. --tier PATH, with a default.""" + name: str + help: str = "" + default: object = None + + def add_to(self, p: argparse.ArgumentParser) -> None: + p.add_argument(self.name, default=self.default, help=self.help) + + +@dataclass +class Flag: + """A boolean flag, e.g. --dry-run (store_true).""" + name: str + help: str = "" + + def add_to(self, p: argparse.ArgumentParser) -> None: + p.add_argument(self.name, action="store_true", help=self.help) + + +@dataclass +class Rest: + """Greedy passthrough of all remaining argv (argparse.REMAINDER).""" + name: str + help: str = "" + + def add_to(self, p: argparse.ArgumentParser) -> None: + p.add_argument(self.name, nargs=argparse.REMAINDER, help=self.help) + + +@dataclass +class Meta: + summary: str + args: List[object] = field(default_factory=list) + + +def build_parser(meta: Meta, prog: str) -> argparse.ArgumentParser: + p = _Parser(prog=prog, description=meta.summary) + for a in meta.args: + a.add_to(p) + return p diff --git a/tests/test_descriptor.py b/tests/test_descriptor.py new file mode 100644 index 0000000..7137235 --- /dev/null +++ b/tests/test_descriptor.py @@ -0,0 +1,39 @@ +# tests/test_descriptor.py +import unittest +from engine.descriptor import Meta, Arg, Flag, Opt, Rest, build_parser + +class TestDescriptor(unittest.TestCase): + def test_required_positional(self): + m = Meta("s", args=[Arg("src", "source")]) + ns = build_parser(m, "apex x").parse_args(["a"]) + self.assertEqual(ns.src, "a") + + def test_optional_positional_defaults_none(self): + m = Meta("s", args=[Arg("src", "s"), Arg("bot", "b", required=False)]) + ns = build_parser(m, "apex x").parse_args(["a"]) + self.assertEqual(ns.src, "a") + self.assertIsNone(ns.bot) + + def test_flag(self): + m = Meta("s", args=[Flag("--dry-run", "d")]) + self.assertFalse(build_parser(m, "x").parse_args([]).dry_run) + self.assertTrue(build_parser(m, "x").parse_args(["--dry-run"]).dry_run) + + def test_opt_with_default(self): + m = Meta("s", args=[Opt("--tier", "t", default="/d")]) + self.assertEqual(build_parser(m, "x").parse_args([]).tier, "/d") + + def test_rest_collects_remainder(self): + m = Meta("s", args=[Arg("action", "a"), Rest("extra", "e")]) + ns = build_parser(m, "x").parse_args(["up", "--force-recreate", "-x"]) + self.assertEqual(ns.action, "up") + self.assertEqual(ns.extra, ["--force-recreate", "-x"]) + + def test_usage_error_exits_64(self): + m = Meta("s", args=[Arg("src", "s")]) + with self.assertRaises(SystemExit) as cm: + build_parser(m, "x").parse_args([]) # missing required + self.assertEqual(cm.exception.code, 64) + +if __name__ == "__main__": + unittest.main()