feat: add engine descriptor module (Meta/Arg/Flag/Opt/Rest, argparse)
This commit is contained in:
1 parent
e9c7e13848
commit
f96c53add9
2 files changed
+116
No files matched your search
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user