From 2fe5c24564eb7cb1283cfab339fda1f8adc1424d Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:55:28 +0200 Subject: [PATCH 1/7] M0 foundation: stdlib-only import, no argh dep, and cw.base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2, #3, #4. Lands #15. #4 — delete cw/scrap.py. 85 dead lines, imported by nothing (verified fleet-wide: t/theremin is cw's only importer and it takes only resolve_to_function). The `"scrap"` entries in pyproject's ruff exclude / per-file-ignores are directory globs from the wads template, not references to the module, and are left in place. #3 — make `import cw` stdlib-only. `from i2.wrapper import Ingress, wrap` was at module scope in cw/resolution.py and used by one function; it now lives in `_i2_wrapper()`, called from the body of `resource_inputs`, and raises an ImportError naming `pip install 'cw[resource]'` when i2 is absent. Measured on p12, best-of-11 subprocess wall clock over a `pass` baseline of 55.8 ms: argparse +0.7 ms cw BEFORE +38.2 ms -X importtime cumulative 37.6 ms cw AFTER +12.3 ms -X importtime cumulative 11.8 ms argh +21.5 ms i2 +34.6 ms cw now imports at ~57% of the argh it replaces, across 66 fleet console scripts. tests/test_import_is_cheap.py guards it two ways: a fresh subprocess asserting no third-party module is present after `import cw`, and a source scan rejecting any module-scope third-party import under cw/. #2 — drop `argh` from dependencies (declared, never imported; LGPL-3.0-or-later, so cw would otherwise be `wads licence-check`'s first finding). `i2` moves to an optional `resource` extra, `argcomplete>=3` gets a `completion` extra, and `dependencies` is now empty. NOTE: thorwhalen/theremin#9 (PR #10) has NOT landed — theremin still imports argh without declaring it on its default branch, and receives it transitively through cw. That PR must merge before cw is released. #15 — cw/base.py: MISSING, HIDE, CommandError, Codec, ArghHelpFormatter, and the Decode/Egress seam contracts. ArghHelpFormatter was written from recorded help *output*, not transcribed from argh's implementation (argh is LGPL-3.0-or-later, cw is MIT): the three behaviours — repr()-ed defaults, None as '-', joined choices, __name__ for anything carrying one — were read off real --help text and then reproduced. A 13-argument differential against live argh 0.31.3's own formatter is byte-identical. The rendering rules live in three class attributes (NONE_IN_HELP, CHOICES_SEPARATOR, render_default) plus a `_help_params` method, so the look is reparametrizable by subclassing rather than by editing cw. Also: tests/ created; pytest now runs doctests (`addopts = --doctest-modules`, `testpaths = ["tests", "cw"]`). 54 tests pass, cw/base.py at 100% coverage. refs #16 #17 #18 #19 #20 #21 Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr --- cw/__init__.py | 74 +++++++- cw/base.py | 318 ++++++++++++++++++++++++++++++++++ cw/resolution.py | 27 ++- cw/scrap.py | 85 --------- pyproject.toml | 13 +- tests/test_base.py | 176 +++++++++++++++++++ tests/test_import_is_cheap.py | 56 ++++++ tests/test_resolution.py | 77 ++++++++ 8 files changed, 733 insertions(+), 93 deletions(-) create mode 100644 cw/base.py delete mode 100644 cw/scrap.py create mode 100644 tests/test_base.py create mode 100644 tests/test_import_is_cheap.py create mode 100644 tests/test_resolution.py diff --git a/cw/__init__.py b/cw/__init__.py index 202354f..c0b9ae8 100644 --- a/cw/__init__.py +++ b/cw/__init__.py @@ -1,5 +1,73 @@ +"""Tools to wire a string-only environment to a Python function call. + +``cw`` is a codec layer between a string-only environment -- the command line -- and a +Python function call, with somewhere to put the ``str -> object`` conversion on the way in +and the ``result -> stdout`` conversion on the way out:: + + argv (strings) + | + | ARGPARSE -- lexing, --help, usage:, subparsers, and the type= site + v + Namespace {dest: str | list[str] | bool | None} + | + | INGRESS -- Namespace -> (args, kwargs), honouring /, *args, * and **kwargs + v + (args, kwargs) --> f --> result + | + | EGRESS -- result -> lines -> exit code + v + stdout + exit code + +Two properties are load-bearing rather than incidental: + +* **cw produces a plain** :class:`argparse.ArgumentParser`, never a subclass, so tools that + are argparse-typed at their signature (``argcomplete.autocomplete``) keep working. +* **Importing ``cw`` costs stdlib only.** The one third-party dependency, ``i2``, is + imported inside :func:`cw.resource_inputs` and nowhere else, and is an optional extra + (``pip install 'cw[resource]'``). ``tests/test_import_is_cheap.py`` asserts this in a + fresh subprocess, which is the only place the claim can honestly be checked. + +>>> import cw +>>> cw.CommandError('no such pipeline', code=2).code +2 +>>> cw.resolve_to_function('builtins.len') is len +True """ -Tools to make CLIs from python functions. -""" -from cw.resolution import resource_inputs, resolve_to_function +from cw.base import ( + ArghHelpFormatter, + Codec, + CommandError, + Decode, + Egress, + HIDE, + MISSING, +) +from cw.resolution import ( + parse_ast_spec, + parse_json_spec, + parse_spec_with_dot_path, + resolve_func_from_dot_path, + resolve_object, + resolve_to_function, + resource_inputs, +) + +__all__ = [ + # -- base: the shared vocabulary --------------------------------------------------- + "ArghHelpFormatter", + "Codec", + "CommandError", + "Decode", + "Egress", + "HIDE", + "MISSING", + # -- resolution: string specification -> callable ----------------------------------- + "parse_ast_spec", + "parse_json_spec", + "parse_spec_with_dot_path", + "resolve_func_from_dot_path", + "resolve_object", + "resolve_to_function", + "resource_inputs", +] diff --git a/cw/base.py b/cw/base.py new file mode 100644 index 0000000..781b71e --- /dev/null +++ b/cw/base.py @@ -0,0 +1,318 @@ +"""The small vocabulary cw's other modules share: sentinels, errors, and help rendering. + +This is the bottom of cw's import graph. Everything else in ``cw`` imports from here and +nothing here imports from ``cw``, so this module stays trivially cheap and trivially +testable. + +What lives here: + +``MISSING`` / ``HIDE`` + Two named singletons. ``MISSING`` means "no value was supplied", which ``None`` cannot + mean because ``None`` is an ordinary default. ``HIDE`` is the ``config`` value meaning + "this parameter is not a command-line argument at all". + +``CommandError`` + The one exception a command may raise to say "this is an expected failure": one line to + stderr, no traceback, and an exit code. + +``ArghHelpFormatter`` + The help look cw reproduces by default -- a raw (unwrapped) description, and a help + column that renders each parameter's default with ``repr``, ``None`` as ``'-'``. + +``Codec`` + The optional, per-parameter, *post-parse* decoder (the "ingress site"), for turning a + token into something argparse's ``type=`` must not be asked to build. + +``Decode`` / ``Egress`` + The call contracts of two of cw's three seams, named once so the rest of the package + (and its users) can spell them. + +>>> HIDE +cw.HIDE +>>> CommandError('nope', code=7).code +7 +""" + +import argparse +import dataclasses +import inspect +from typing import Any, Callable, Container, Mapping, TypeAlias, Union + +__all__ = [ + "MISSING", + "HIDE", + "CommandError", + "ArghHelpFormatter", + "Codec", + "Decode", + "Egress", + "DFLT_HELP", + "DFLT_ERROR_CODE", +] + + +# -------------------------------------------------------------------------------------- +# Sentinels + + +class _Sentinel: + """A named singleton whose ``repr`` is useful in a traceback or a help string. + + >>> _Sentinel('cw.NOPE') + cw.NOPE + >>> bool(_Sentinel('cw.NOPE')) + True + """ + + __slots__ = ("_name",) + + def __init__(self, name: str): + self._name = name + + def __repr__(self) -> str: + return self._name + + +#: "No value was supplied" -- distinct from ``None``, which is an ordinary default value. +MISSING = _Sentinel("cw.MISSING") + +#: A ``config`` value meaning "this parameter is not a command-line argument at all". +#: +#: The function's own default applies instead. Its v1 consumer is a +#: :func:`functools.partial` whose pre-bound keyword :func:`inspect.signature` still +#: re-exposes; ``HIDE`` is how you stop that keyword from becoming a flag. +#: +#: ``HIDE`` is used rather than the spelling ``config[param] = None`` because ``None`` is a +#: perfectly ordinary default value, and overloading it would be a latent bug. +HIDE = _Sentinel("cw.HIDE") + + +# -------------------------------------------------------------------------------------- +# Seam contracts +# +# Named here so that cw, and anyone replacing one of cw's defaults, spell them the same way. + +#: Seam 1. ``(parameter, hint) -> add_argument kwargs``. +#: +#: Given a :class:`inspect.Parameter` and its type hint (whatever +#: ``Convention.resolve_hints`` decided a "hint" is), return one of: +#: +#: * ``None`` -- infer nothing for this parameter; argparse leaves the token a ``str``; +#: * a callable -- shorthand for ``{'type': that_callable}``; +#: * a Mapping -- *any* ``add_argument`` kwargs (``type``, ``nargs``, ``choices``, +#: ``action``, ``const``, ``required``, ...). +#: +#: The Mapping return is not optional generality: a type implies more than a converter +#: (``list[str]`` implies ``nargs='*'``, ``Literal['a', 'b']`` implies ``choices``), so a +#: seam that could only return a callable could not carry the grammar it exists to carry. +Decode: TypeAlias = Callable[ + [inspect.Parameter, Any], Union[None, Callable[[str], Any], Mapping[str, Any]] +] + +#: Seam 2. ``(result, *, out, err) -> exit code``. +#: +#: How a function's return value becomes lines on a stream and a process exit code. +Egress: TypeAlias = Callable[..., int] + + +# -------------------------------------------------------------------------------------- +# Errors + +#: The exit code a :class:`CommandError` uses when none is given. +DFLT_ERROR_CODE = 1 + + +class CommandError(Exception): + """An *expected* failure: one line to stderr, no traceback, ``exit(code)``. + + Anything else a command raises keeps its traceback, because an unexpected failure is a + bug and a bug deserves one. + + Args: + message: The single line written to the error stream. + code: The process exit code. Keyword-only. + + >>> err = CommandError('no such thing') + >>> str(err), err.code + ('no such thing', 1) + >>> CommandError('bad config', code=7).code + 7 + + It is an ordinary exception, so it raises and catches like one: + + >>> try: + ... raise CommandError('nope', code=3) + ... except CommandError as err: + ... print(f'{err} -> exit {err.code}') + nope -> exit 3 + """ + + def __init__(self, message: str = "", *, code: int = DFLT_ERROR_CODE): + self.code = code + super().__init__(message) + + +# -------------------------------------------------------------------------------------- +# The ingress-site codec + + +@dataclasses.dataclass(frozen=True) +class Codec: + """A per-parameter, post-parse decoder for one parameter (the "ingress site"). + + argparse's ``type=`` is the wrong place for a *semantic* decoder -- one that maps a + token to something that is not a scalar -- because argparse also applies ``type=`` to a + string ``default`` and to a ``const``. A decoder that resolves names to objects would + therefore be handed the sentinel and the default too. So cw offers a second, + **opt-in**, per-parameter site, which runs after parsing. + + Two fields and deliberately nothing else: ``nargs`` / ``const`` / ``default`` / ``help`` + / flag spellings are *token grammar* and belong in ``config``, next to every other + ``add_argument`` keyword. + + Args: + decode: ``value -> value``. Called only on strings. + passthrough: Values ``decode`` must never see, so a sentinel can survive. + + It applies if and only if the value is a ``str``, which is argparse's own ``type=`` + rule: + + >>> codec = Codec(decode=str.upper) + >>> codec('abc') + 'ABC' + >>> codec(3) + 3 + >>> codec(None) is None + True + + Elementwise for a list-valued (``nargs``) parameter: + + >>> codec(['a', 'b']) + ['A', 'B'] + + And never on a passthrough value -- which is what lets a sentinel survive: + + >>> pick = Codec(decode=str.upper, passthrough={'list'}) + >>> pick('list'), pick('bass') + ('list', 'BASS') + """ + + decode: Callable[[Any], Any] + passthrough: Container = () + + def __call__(self, value: Any) -> Any: + if isinstance(value, str): + return value if value in self.passthrough else self.decode(value) + if isinstance(value, list): + return [self(element) for element in value] + return value + + +# -------------------------------------------------------------------------------------- +# Help rendering + +#: The help string cw gives a parameter that has none, so its default shows in the help +#: column. Turned off by ``Convention.default_in_help=False``. +DFLT_HELP = "%(default)s" + + +class ArghHelpFormatter( + argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter +): + """The default help look: raw description, ``repr``-ed defaults, ``None`` as ``'-'``. + + Three behaviours, all reproduced from recorded help output rather than transcribed from + any implementation: + + 1. the description and epilog are **not** re-wrapped (from + :class:`argparse.RawDescriptionHelpFormatter`); + 2. a help string that does not already mention the default gets + ``' (default: ...)'`` appended (from + :class:`argparse.ArgumentDefaultsHelpFormatter`); + 3. every ``%(...)s`` field in a help string is filled from the action, with three + adjustments: a ``default`` renders as ``repr(default)``, or as + ``NONE_IN_HELP`` when it is ``None``; anything carrying a ``__name__`` + (a ``type``, a callable default) renders as that name; and ``choices`` render + joined by ``CHOICES_SEPARATOR``. + + The three class attributes below are the parametrization point -- subclass and rebind + one to change the look, and pass your subclass as ``formatter_class``. + + >>> import argparse + >>> parser = argparse.ArgumentParser(prog='demo', add_help=False, + ... formatter_class=ArghHelpFormatter) + >>> _ = parser.add_argument('--scale', default=None, help=DFLT_HELP) + >>> _ = parser.add_argument('--name', default='ann', help=DFLT_HELP) + >>> _ = parser.add_argument('--n', default=3, help=DFLT_HELP) + >>> _ = parser.add_argument('--flag', action='store_true', help=DFLT_HELP) + >>> collapsed = ' '.join(parser.format_help().split()) + + ``None`` is a dash, and every other default is its ``repr``: + + >>> '--scale SCALE -' in collapsed + True + >>> "--name NAME 'ann'" in collapsed + True + >>> '--n N 3' in collapsed + True + >>> '--flag False' in collapsed + True + + A help string of your own keeps the ``ArgumentDefaultsHelpFormatter`` suffix, and the + default in it is ``repr``-ed too: + + >>> _ = parser.add_argument('--mine', default='e', help='an explicit help string') + >>> "an explicit help string (default: 'e')" in ' '.join(parser.format_help().split()) + True + + A ``type`` or a callable default renders by name rather than by ``repr``: + + >>> parser = argparse.ArgumentParser(prog='demo', add_help=False, + ... formatter_class=ArghHelpFormatter) + >>> _ = parser.add_argument('--n', type=int, default=1, help='%(type)s') + >>> _ = parser.add_argument('--pick', choices=['a', 'b'], help='%(choices)s') + >>> collapsed = ' '.join(parser.format_help().split()) + >>> '--n N int' in collapsed + True + >>> '--pick {a,b} a, b' in collapsed + True + """ + + #: What a ``None`` default renders as in the help column. + NONE_IN_HELP = "-" + + #: What ``choices`` are joined by when a help string interpolates ``%(choices)s``. + CHOICES_SEPARATOR = ", " + + #: How a non-``None`` default is rendered. ``repr``, so a ``str`` keeps its quotes and + #: is distinguishable from the bare word. + render_default = staticmethod(repr) + + def _help_params(self, action: argparse.Action) -> dict: + """The ``%(...)s`` substitution namespace for one action's help string. + + Separated from :meth:`_expand_help` so the *rendering* rules can be read, tested, + and overridden without touching argparse's interpolation call. + """ + params = dict(vars(action), prog=self._prog) + params = { + name: value + for name, value in params.items() + if value is not argparse.SUPPRESS + } + params = { + name: getattr(value, "__name__", value) for name, value in params.items() + } + if params.get("choices") is not None: + params["choices"] = self.CHOICES_SEPARATOR.join( + str(choice) for choice in params["choices"] + ) + if "default" in params: + default = params["default"] + params["default"] = ( + self.NONE_IN_HELP if default is None else self.render_default(default) + ) + return params + + def _expand_help(self, action: argparse.Action) -> str: + return self._get_help_string(action) % self._help_params(action) diff --git a/cw/resolution.py b/cw/resolution.py index 2829cc7..ba78e78 100644 --- a/cw/resolution.py +++ b/cw/resolution.py @@ -424,7 +424,30 @@ def resolve_to_function( from typing import Dict, Any, Optional, Union from collections.abc import Callable from cw.resolution import resolve_to_function -from i2.wrapper import Ingress, wrap + + +def _i2_wrapper(): + """Import ``i2.wrapper``'s ``Ingress`` and ``wrap``, lazily. + + ``i2`` is the only third-party dependency ``cw`` has, it is used by exactly one + function (:func:`resource_inputs`), and importing it costs ~35 ms. Keeping the + import here is what makes ``import cw`` stdlib-only -- which is load-bearing for + the repos that ship their CLI as an optional extra to keep the base install thin. + + Returns: + The ``(Ingress, wrap)`` pair from :mod:`i2.wrapper`. + + Raises: + ImportError: with the install command, when ``i2`` is not available. + """ + try: + from i2.wrapper import Ingress, wrap + except ImportError as error: + raise ImportError( + "cw.resource_inputs needs the optional 'i2' dependency. " + "Install it with: pip install 'cw[resource]' (or: pip install i2)" + ) from error + return Ingress, wrap def _resolve_resource_spec(resource_spec, default_ingress: Callable) -> Callable: @@ -560,6 +583,8 @@ def resource_inputs( # Create the kwargs transformation function kwargs_trans = _create_resource_kwargs_trans(resource_resolvers) + Ingress, wrap = _i2_wrapper() + # Create ingress using the Ingress class ingress = Ingress( inner_sig=func, diff --git a/cw/scrap.py b/cw/scrap.py deleted file mode 100644 index 7ee2911..0000000 --- a/cw/scrap.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Scrap""" - -import argparse -import sys -import inspect - - -class NoDefault: - def __repr__(self): - return "no_default" - - -no_default = NoDefault() - - -def arg_dflt_dict_of_callable(f): - """ - Get a {arg_name: default_val, ...} dict from a callable. - See also :py:mint_of_callable: - :param f: A callable (function, method, ...) - :return: - """ - argspec = inspect.getfullargspec(f) - args = argspec.args or [] - defaults = argspec.defaults or [] - return { - arg: dflt - for arg, dflt in zip( - args, [no_default] * (len(args) - len(defaults)) + list(defaults) - ) - } - - -# TODO: Separate mint -# TODO: Make minting use a whitelist (inclusion list) - - -def subparser_for_func(func, subparser_handle, func_name=None): - """ - - :param func: function object - :param subparser_handle: - :param func_name: function name - - :return: - """ - if func_name is None: - func_name = func.__name__ - - func_parser = subparser_handle.add_parser(func_name, help=inspect.getdoc(func)) - func_parser.set_defaults(which=func_name) - - for arg, dflt in arg_dflt_dict_of_callable(func): - if ( - arg == "self" or arg == "cls" - ): # TODO: Not completely safe. Make safer. See i2i.util - pass - elif dflt is no_default: - func_parser.add_argument("--" + arg) - else: - func_parser.add_argument("--" + arg, default=dflt) - - -def is_method_or_function(o): - return inspect.ismethod(o) or inspect.isfunction(o) - - -def parser_for_class(o): - parser = argparse.ArgumentParser() - subparser_handle = parser.add_subparsers() - info = inspect.getmembers(o, predicate=is_method_or_function) - for func_name, func in info: - print(func_name) - subparser_for_func(func, subparser_handle=subparser_handle, func_name=func_name) - return parser - - -def py2cli_test(cls, input_string): - sys.argv = input_string.split(" ") - parser = parser_for_class(cls) - cl_in = parser.parse_args() - # print(cl_in) - func = getattr(cls, cl_in.which) - del cl_in.which - return func(**vars(cl_in)) diff --git a/pyproject.toml b/pyproject.toml index 37725f8..121b733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,7 @@ readme = "README.md" requires-python = ">=3.10" keywords = [] authors = [] -dependencies = [ - "i2", - "argh", -] +dependencies = [] [project.license] text = "MIT" @@ -24,6 +21,12 @@ text = "MIT" Homepage = "https://github.com/i2mint/cw" [project.optional-dependencies] +resource = [ + "i2", +] +completion = [ + "argcomplete>=3", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", @@ -76,7 +79,9 @@ convention = "google" minversion = "6.0" testpaths = [ "tests", + "cw", ] +addopts = "--doctest-modules" doctest_optionflags = [ "NORMALIZE_WHITESPACE", "ELLIPSIS", diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..e0814fc --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,176 @@ +"""Tests for ``cw.base``: sentinels, ``CommandError``, ``Codec``, ``ArghHelpFormatter``. + +The ``ArghHelpFormatter`` cases are *goldens*: each expected string was recorded from real +help output, not derived from any implementation. If one of them changes, the help column +changed, and that is a grammar break. +""" + +import argparse + +import pytest + +from cw.base import ( + ArghHelpFormatter, + Codec, + CommandError, + DFLT_HELP, + HIDE, + MISSING, +) + + +# -------------------------------------------------------------------------------------- +# Sentinels + + +def test_sentinels_are_distinct_and_are_not_none(): + assert HIDE is not MISSING + assert HIDE is not None and MISSING is not None + assert HIDE is not False and MISSING is not False + + +def test_sentinels_repr_readably(): + assert repr(HIDE) == "cw.HIDE" + assert repr(MISSING) == "cw.MISSING" + + +# -------------------------------------------------------------------------------------- +# CommandError + + +def test_command_error_defaults_to_exit_code_one(): + assert CommandError().code == 1 + assert CommandError("boom").code == 1 + assert str(CommandError("boom")) == "boom" + + +def test_command_error_carries_an_explicit_code(): + assert CommandError("boom", code=7).code == 7 + + +def test_command_error_code_is_keyword_only(): + with pytest.raises(TypeError): + CommandError("boom", 7) + + +def test_command_error_is_catchable_as_an_exception(): + with pytest.raises(CommandError) as caught: + raise CommandError("boom", code=3) + assert caught.value.code == 3 + + +# -------------------------------------------------------------------------------------- +# Codec + + +@pytest.mark.parametrize( + "value, expected", + [ + ("abc", "ABC"), # a str is decoded + (3, 3), # a non-str is not -- argparse's own `type=` rule + (None, None), + (True, True), + (["a", "b"], ["A", "B"]), # elementwise, for an nargs parameter + ([], []), + ([1, "a"], [1, "A"]), + ], +) +def test_codec_applies_only_to_strings(value, expected): + assert Codec(decode=str.upper)(value) == expected + + +def test_codec_never_sees_a_passthrough_value(): + codec = Codec(decode=str.upper, passthrough={"list"}) + assert codec("list") == "list" + assert codec("bass") == "BASS" + assert codec(["list", "bass"]) == ["list", "BASS"] + + +def test_codec_is_frozen(): + with pytest.raises(Exception): + Codec(decode=str.upper).decode = str.lower + + +# -------------------------------------------------------------------------------------- +# ArghHelpFormatter -- recorded goldens + + +def _help_of(*add_argument_calls, description=None): + """Collapsed help text for a parser carrying the given ``add_argument`` calls.""" + parser = argparse.ArgumentParser( + prog="x", + add_help=False, + description=description, + formatter_class=ArghHelpFormatter, + ) + for args, kwargs in add_argument_calls: + parser.add_argument(*args, **kwargs) + return " ".join(parser.format_help().split()) + + +@pytest.mark.parametrize( + "default, expected_column", + [ + (None, "-"), # None renders as a dash, not as "None" + (False, "False"), + (True, "True"), + ("hi", "'hi'"), # a str keeps its quotes + (3, "3"), + (1.5, "1.5"), + ([], "[]"), + ((), "()"), + ], +) +def test_help_column_renders_repr_of_the_default(default, expected_column): + help_text = _help_of((("--param",), dict(default=default, help=DFLT_HELP))) + assert f"--param PARAM {expected_column}" in help_text + + +def test_help_column_renders_a_callable_default_by_name(): + def helper(): + pass # pragma: no cover + + help_text = _help_of((("--param",), dict(default=helper, help=DFLT_HELP))) + assert "--param PARAM 'helper'" in help_text + + +def test_an_explicit_help_string_keeps_the_appended_default(): + help_text = _help_of((("--param",), dict(default="e", help="an explicit one"))) + assert "--param PARAM an explicit one (default: 'e')" in help_text + + +def test_help_interpolates_prog_type_and_choices(): + help_text = _help_of( + (("--p",), dict(default="w", help="prog is %(prog)s")), + (("--t",), dict(type=int, default=1, help="type is %(type)s")), + (("--c",), dict(choices=["a", "b"], default="a", help="pick %(choices)s")), + ) + assert "--p P prog is x" in help_text + assert "--t T type is int" in help_text + assert "--c {a,b} pick a, b" in help_text + + +def test_description_is_not_rewrapped(): + """The ``RawDescriptionHelpFormatter`` half: line breaks in a description survive.""" + parser = argparse.ArgumentParser( + prog="x", + add_help=False, + description="line one\n line two, indented", + formatter_class=ArghHelpFormatter, + ) + assert "line one\n line two, indented" in parser.format_help() + + +def test_the_look_is_reparametrizable_by_subclassing(): + """The three class attributes are the open-closed seam; no cw code needs editing.""" + + class Loud(ArghHelpFormatter): + NONE_IN_HELP = "NOTHING" + CHOICES_SEPARATOR = " | " + + parser = argparse.ArgumentParser(prog="x", add_help=False, formatter_class=Loud) + parser.add_argument("--param", default=None, help=DFLT_HELP) + parser.add_argument("--c", choices=["a", "b"], default="a", help="%(choices)s") + help_text = " ".join(parser.format_help().split()) + assert "--param PARAM NOTHING" in help_text + assert "--c {a,b} a | b" in help_text diff --git a/tests/test_import_is_cheap.py b/tests/test_import_is_cheap.py new file mode 100644 index 0000000..82a7e33 --- /dev/null +++ b/tests/test_import_is_cheap.py @@ -0,0 +1,56 @@ +"""``import cw`` must stay stdlib-only. + +This is a regression guard, not a benchmark. cw is offered to repos that ship their CLI as +an optional extra precisely to keep the base install thin, so a module-scope third-party +import anywhere under ``cw/`` is a defect even when the dependency happens to be installed. +The check runs in a subprocess because the test session itself has already imported plenty. +""" + +import subprocess +import sys +import textwrap + +#: Distributions cw must not pull at import time. ``i2`` is an optional extra used by +#: ``resource_inputs`` alone; ``argh`` is the LGPL package cw exists to replace. +FORBIDDEN_AT_IMPORT = ("i2", "argh", "dol", "meshed", "argcomplete") + + +def _modules_after_importing_cw(): + """Top-level module names present in a fresh interpreter after ``import cw``.""" + source = textwrap.dedent( + """ + import sys + import cw + print('\\n'.join(sorted({name.split('.')[0] for name in sys.modules}))) + """ + ) + completed = subprocess.run( + [sys.executable, "-c", source], capture_output=True, text=True, check=True + ) + return set(completed.stdout.split()) + + +def test_import_cw_pulls_no_third_party(): + pulled = _modules_after_importing_cw() & set(FORBIDDEN_AT_IMPORT) + assert not pulled, ( + f"`import cw` pulled {sorted(pulled)}. Move that import into the body of the one " + f"function that needs it -- see cw.resolution._i2_wrapper for the pattern." + ) + + +def test_no_module_scope_third_party_import_in_cw(): + """The mechanical version of the check above, so a new module cannot sneak one in.""" + import pathlib + + import cw + + package_dir = pathlib.Path(cw.__file__).parent + offenders = {} + for module_path in sorted(package_dir.glob("*.py")): + for lineno, line in enumerate(module_path.read_text().splitlines(), start=1): + if not line.startswith(("import ", "from ")): + continue # indented -> inside a function or class; that is the allowed form + root = line.split()[1].split(".")[0] + if root in FORBIDDEN_AT_IMPORT: + offenders[f"{module_path.name}:{lineno}"] = line.strip() + assert not offenders, f"module-scope third-party imports: {offenders}" diff --git a/tests/test_resolution.py b/tests/test_resolution.py new file mode 100644 index 0000000..763bd9a --- /dev/null +++ b/tests/test_resolution.py @@ -0,0 +1,77 @@ +"""Tests for ``cw.resolution``'s public surface and its lazy ``i2`` import. + +``cw.resolution`` is pre-existing, working code with a live dependent, so these tests pin +the two things the v1 work could plausibly have broken: the names other packages import, +and ``resource_inputs``, whose ``i2`` import moved from module scope into the function body. +""" + +import builtins +import sys + +import pytest + +import cw +from cw.resolution import parse_ast_spec, resource_inputs + + +#: What other packages import from cw today, plus the rest of the module's stated API. +PUBLIC_RESOLUTION_NAMES = ( + "parse_ast_spec", + "parse_json_spec", + "parse_spec_with_dot_path", + "resolve_func_from_dot_path", + "resolve_object", + "resolve_to_function", + "resource_inputs", +) + + +@pytest.mark.parametrize("name", PUBLIC_RESOLUTION_NAMES) +def test_resolution_names_are_reachable_from_the_package_root(name): + """`t/theremin` does ``from cw import resolve_to_function``. Keep that true.""" + assert callable(getattr(cw, name)) + assert name in cw.__all__ + + +def test_resolve_to_function_still_resolves_a_dot_path(): + assert cw.resolve_to_function("builtins.len") is len + + +def test_resource_inputs_resolves_the_parameters_it_is_given(): + def func(apple, banana, carrot): + return apple, banana, carrot + + function_store = {"a": lambda: 1} + wrapped = resource_inputs( + func, + resource=dict( + apple=None, + carrot=dict( + func_key_and_kwargs=parse_ast_spec, get_func=function_store.get + ), + ), + ) + apple, banana, carrot = wrapped("builtins.len", "test", "a()") + assert apple is len + assert banana == "test" # no resource declared -> passed through untouched + assert callable(carrot) + + +def test_resource_inputs_says_which_extra_to_install_when_i2_is_missing(monkeypatch): + """The import moved into the body, so its failure must name the fix.""" + real_import = builtins.__import__ + + def blocking_import(name, *args, **kwargs): + if name == "i2" or name.startswith("i2."): + raise ImportError(f"No module named {name!r}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", blocking_import) + for name in [name for name in sys.modules if name.split(".")[0] == "i2"]: + monkeypatch.delitem(sys.modules, name) + + def func(apple): + return apple # pragma: no cover + + with pytest.raises(ImportError, match=r"cw\[resource\]"): + resource_inputs(func, resource=dict(apple=None)) From cf4f32f906e8eb237307a446405fee1faa898c81 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:18:17 +0200 Subject: [PATCH 2/7] cw.grammar: argh 0.31.3's signature inference, rewritten fresh refs #16, partially #17 argh reaches its grammar through two uncoordinated inference paths -- the annotation guesser (assembling.py:741-801) and the default-value guesser (:311-364) -- which collide on `bool` and are reconciled by two copy-pasted hand-patches. cw states them once, with one precedence order, in `specs_for_function`. Written from behaviour, not from argh's source: argh is LGPL-3.0-or-later, cw is MIT. `cw/` imports argh nowhere; `argh==0.31.3` is pinned in the dev extra only, for the differential. - cw/grammar.py: ArgSpec (argh's field-specific merge per ADR-0003, NOT dict.update), argh_decode / modern_decode (seam 1), cli_name / command_name, the short-flag collision rule, the flag append-merge rule, the four-tier ladder. Never imports argparse; ArgSpec.add_argument_args() is the only place that knows what an add_argument call looks like. - cw/convention.py: Convention / ARGH / MODERN, minus the `egress` field, which lands with cw.egress (#20). - tests/argh_parity/: builds the same parser with argh and with cw for a 32-function corpus x 2 naming policies, and asserts the argparse action tables match field-by-field AND `--help` is byte-identical. - tests/test_grammar.py: the errors, the MODERN improvements (all defaulting OFF), and the negative control proving no Convention switch is dead. Verified: 326 passed, 2 skipped; cw/grammar.py and cw/convention.py at 100% statement coverage; eight deliberate mutations of the grammar each break the differential. --- cw/__init__.py | 24 + cw/convention.py | 114 +++++ cw/grammar.py | 767 +++++++++++++++++++++++++++++++ pyproject.toml | 4 + tests/__init__.py | 0 tests/argh_parity/__init__.py | 0 tests/argh_parity/corpus.py | 299 ++++++++++++ tests/argh_parity/harness.py | 166 +++++++ tests/argh_parity/test_parity.py | 192 ++++++++ tests/test_grammar.py | 531 +++++++++++++++++++++ 10 files changed, 2097 insertions(+) create mode 100644 cw/convention.py create mode 100644 cw/grammar.py create mode 100644 tests/__init__.py create mode 100644 tests/argh_parity/__init__.py create mode 100644 tests/argh_parity/corpus.py create mode 100644 tests/argh_parity/harness.py create mode 100644 tests/argh_parity/test_parity.py create mode 100644 tests/test_grammar.py diff --git a/cw/__init__.py b/cw/__init__.py index c0b9ae8..ce640c3 100644 --- a/cw/__init__.py +++ b/cw/__init__.py @@ -43,6 +43,19 @@ HIDE, MISSING, ) +from cw.convention import ( + ARGH, + BY_NAME_IF_HAS_DEFAULT, + BY_NAME_IF_KWONLY, + MODERN, + Convention, +) +from cw.grammar import ( + GrammarError, + argh_decode, + command_name, + modern_decode, +) from cw.resolution import ( parse_ast_spec, parse_json_spec, @@ -62,6 +75,17 @@ "Egress", "HIDE", "MISSING", + # -- grammar: a signature becomes command-line arguments ---------------------------- + "GrammarError", + "argh_decode", + "command_name", + "modern_decode", + # -- convention: what the defaults ARE ---------------------------------------------- + "ARGH", + "BY_NAME_IF_HAS_DEFAULT", + "BY_NAME_IF_KWONLY", + "MODERN", + "Convention", # -- resolution: string specification -> callable ----------------------------------- "parse_ast_spec", "parse_json_spec", diff --git a/cw/convention.py b/cw/convention.py new file mode 100644 index 0000000..477afee --- /dev/null +++ b/cw/convention.py @@ -0,0 +1,114 @@ +"""What cw's defaults ARE, as one frozen value per context. + +``config`` and ``convention`` are the fleet's existing pair of words -- ``streamlitfront``'s +``mk_app(objs, config=None, convention=None)`` already uses them with exactly these +meanings, so cw rhymes with it rather than inventing a third vocabulary: + +``convention`` + *What the defaults are.* A frozen dataclass, swapped per **context** -- a repo, a + house style, the whole fleet. Rebinding it is how a project's per-call ``config`` + entries shrink to nothing. + +``config`` + *This call's particulars.* A plain mapping, swapped per **call**. + +Two values ship: + +>>> ARGH.naming, ARGH.short_flags, ARGH.resolve_hints +('by_name_if_has_default', True, False) +>>> MODERN.naming, MODERN.resolve_hints, MODERN.hints_when_declared +('by_name_if_kwonly', True, True) + +:data:`ARGH` is the default everywhere, and it reproduces argh 0.31.3 including the parts +nobody likes. Every improvement is opt-in, and opting in is **one** act: + +>>> import functools, cw +>>> dispatch = functools.partial(cw.dispatch, convention=MODERN) # doctest: +SKIP + +That is the whole mechanism -- there is no ``cw.bind`` and no ``set_dispatch_defaults``. +Anything in between is a :func:`dataclasses.replace`: + +>>> import dataclasses +>>> half_way = dataclasses.replace(ARGH, resolve_hints=True) +>>> half_way.resolve_hints, half_way.naming +(True, 'by_name_if_has_default') + +Seam 2 (``egress``) becomes a field here the moment ``cw.egress`` exists; it is left out +rather than declared as a ``None`` that would have to mean two things at once. + +``decode`` is a field rather than only a ``dispatch`` keyword for one reason: if ``MODERN`` +did not carry its own, flipping to it would need a coordinated two-keyword edit at every +call site, and a seam whose replacement touches every caller is in the wrong place. +""" + +import dataclasses + +from cw.base import Decode +from cw.grammar import ( + BY_NAME_IF_HAS_DEFAULT, + BY_NAME_IF_KWONLY, + argh_decode, + modern_decode, +) + +__all__ = [ + "Convention", + "ARGH", + "MODERN", + "BY_NAME_IF_HAS_DEFAULT", + "BY_NAME_IF_KWONLY", +] + + +@dataclasses.dataclass(frozen=True) +class Convention: + """The grammar switches, as one hashable value. + + Every field is read somewhere in ``cw.grammar`` or ``cw.commands``; a field that + changed nothing would be exactly the speculative generality this design refuses. + + >>> Convention() == ARGH + True + >>> hash(Convention()) == hash(ARGH) + True + """ + + #: ``BY_NAME_IF_HAS_DEFAULT`` (argh's legacy rule: a default makes it an option) or + #: ``BY_NAME_IF_KWONLY`` (keyword-only makes it an option; a default only makes a + #: positional optional). + naming: str = BY_NAME_IF_HAS_DEFAULT + #: Infer ``-x`` from a parameter's first character -- suppressed when two parameters + #: share one, and always lost to ``--help`` for ``h``. + short_flags: bool = True + #: ``a_b`` becomes the command ``a-b``. + hyphenate_commands: bool = True + #: ``git_ops`` becomes the group ``git-ops``. argh leaves a group name verbatim. + hyphenate_groups: bool = False + #: Give every argument the help string ``'%(default)s'``. + default_in_help: bool = True + #: Keep inferring from type hints even when a parameter has been overridden. argh + #: switches hint inference off for the *whole function* as soon as one ``@arg`` + #: appears; ADR-0003 extends "overridden" to cover ``config`` entries too. + hints_when_declared: bool = False + #: Resolve annotations with :func:`typing.get_type_hints` instead of reading + #: ``__annotations__`` raw. ``False`` reproduces argh's blindness to PEP 563, under + #: which every annotation is a string and therefore infers nothing. + resolve_hints: bool = False + #: Seam 1: ``(Parameter, hint) -> add_argument kwargs | callable | None``. + decode: Decode = argh_decode + + +#: cw's default in every entry point: argh 0.31.3's grammar, footguns included. +ARGH = Convention() + +#: The same machinery with the improvements switched on. ``naming`` follows parameter +#: kind rather than the presence of a default, annotations are actually resolved and +#: still consulted when a parameter is overridden, groups are hyphenated like commands, +#: and ``decode`` additionally understands ``Optional[X]``, ``Enum`` and ``pathlib``. +MODERN = Convention( + naming=BY_NAME_IF_KWONLY, + hyphenate_groups=True, + hints_when_declared=True, + resolve_hints=True, + decode=modern_decode, +) diff --git a/cw/grammar.py b/cw/grammar.py new file mode 100644 index 0000000..fc7e47f --- /dev/null +++ b/cw/grammar.py @@ -0,0 +1,767 @@ +"""How a Python signature becomes command-line arguments. + +This is cw's riskiest module and its most opinionated one: it reproduces argh 0.31.3's +signature-to-``argparse`` inference exactly, footguns included, because a CLI that reads +*almost* like the one it replaces is worse than one that reads nothing like it. + +argh arrives at its grammar through two uncoordinated code paths -- an annotation guesser +(``TypingHintArgSpecGuesser``) and a default-value guesser +(``guess_extra_parser_add_argument_spec_kwargs``) -- which collide on ``bool`` and are +reconciled by two copy-pasted hand-patches. Here they are **one function with one +precedence order**: :func:`specs_for_function`. + +Three things this module deliberately does *not* do: + +* It never imports :mod:`argparse`. It produces :class:`ArgSpec` values -- plain data -- + and :meth:`ArgSpec.add_argument_args` turns one into the ``(args, kwargs)`` of an + ``add_argument`` call. ``cw.cli`` is the only module that makes that call. +* It has no notion of a command tree, a parser, or a namespace. One function in, a list + of arguments out. +* It hardcodes no policy. Every switch is read off the ``convention`` argument, so + ``convention=cw.MODERN`` is one act that changes all of them at once. + +The whole grammar in one look: + +>>> def greet(name, greeting='hello', *, shout=False, times: int = 1): +... 'Say hello.' +>>> for spec in specs_for_function(greet): +... print(spec.param_name, spec.flags, spec.add_argument_kwargs()) +name ['name'] {'help': '%(default)s'} +greeting ['-g', '--greeting'] {'default': 'hello', 'type': , 'help': '%(default)s'} +shout ['-s', '--shout'] {'default': False, 'action': 'store_true', 'help': '%(default)s'} +times ['-t', '--times'] {'default': 1, 'type': , 'help': '%(default)s'} + +Read that output slowly, because every line of it is an argh compatibility decision: +``greeting`` has a default so it becomes an *option* (``BY_NAME_IF_HAS_DEFAULT``); +``shout=False`` becomes ``store_true`` (and ``shout=True`` would become ``store_false``); +the short flags come from first characters and would vanish entirely if two parameters +shared one; and every help string is ``'%(default)s'``, which +:class:`cw.base.ArghHelpFormatter` renders as ``repr(default)``. +""" + +import dataclasses +import enum +import inspect +import pathlib +import typing +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence + +from cw.base import HIDE, MISSING, DFLT_HELP, Codec + +__all__ = [ + "ArgSpec", + "GrammarError", + "argh_decode", + "modern_decode", + "cli_name", + "command_name", + "infer_specs", + "specs_for_function", + "BY_NAME_IF_HAS_DEFAULT", + "BY_NAME_IF_KWONLY", + "ZERO_OR_MORE", + "OPTIONAL", +] + + +# -------------------------------------------------------------------------------------- +# Vocabulary +# +# These are argparse's own spellings, repeated here as constants rather than imported, +# because this module must not import argparse (see the module docstring). + +#: ``argparse.ZERO_OR_MORE`` -- "any number of values, including none". +ZERO_OR_MORE = "*" + +#: ``argparse.OPTIONAL`` -- "one value, or none". +OPTIONAL = "?" + +#: A parameter is named on the command line iff it has a default value (argh's legacy +#: policy, and the one every fleet script was written against). +BY_NAME_IF_HAS_DEFAULT = "by_name_if_has_default" + +#: A parameter is named on the command line iff it is keyword-only. Position and +#: optionality become independent, which is what most people expect. +BY_NAME_IF_KWONLY = "by_name_if_kwonly" + +#: The types argh's annotation guesser recognises as scalars. Nothing more, because argh +#: has nothing more. +BASIC_TYPES = (str, int, float, bool) + +#: ``add_argument`` kwargs that :meth:`ArgSpec.update` merges field-by-field rather than +#: by ``dict.update`` -- see :class:`ArgSpec`. +FIELD_MERGED_KEYS = ("required", "nargs", "default") + +_UNION_TYPES: tuple = (typing.Union,) +try: # pragma: no cover - py>=3.10 always has it; the fallback is for older readers + from types import UnionType + + _UNION_TYPES = (typing.Union, UnionType) +except ImportError: # pragma: no cover + pass + +_NONE_TYPE = type(None) + + +class GrammarError(ValueError): + """A signature and its overrides cannot be reconciled into a CLI. + + Raised eagerly, at parser-construction time, so a mis-keyed ``config`` entry is a + startup failure with a message rather than a flag that silently never appears. + """ + + +# -------------------------------------------------------------------------------------- +# The unit of the grammar + + +@dataclasses.dataclass +class ArgSpec: + """One command-line argument, as data, before argparse ever sees it. + + ``required``, ``default`` and ``nargs`` are held in their own fields rather than in + ``extra`` because argh merges them by rules that ``dict.update`` does not have (see + :meth:`update`), and reproducing that merge is what makes ``--help`` byte-identical. + + >>> spec = ArgSpec('verbose', ['-v', '--verbose'], default=False) + >>> spec.add_argument_args() + (('-v', '--verbose'), {'default': False}) + >>> spec.is_positional + False + """ + + #: The name of the Python parameter this argument feeds. + param_name: str + #: Option strings (``['-v', '--verbose']``) or a single positional CLI name. + flags: List[str] + #: ``add_argument(required=...)``; ``MISSING`` means "do not pass it". + required: Any = MISSING + #: ``add_argument(default=...)``; ``MISSING`` means "do not pass it". + default: Any = MISSING + #: ``add_argument(nargs=...)``; ``None`` means "do not pass it". + nargs: Optional[str] = None + #: Every other ``add_argument`` keyword: ``type``, ``action``, ``choices``, ``help``... + extra: Dict[str, Any] = dataclasses.field(default_factory=dict) + #: The optional post-parse decoder (``cw.ingress``'s site, not argparse's ``type=``). + codec: Optional[Codec] = None + #: Set by a ``cw.HIDE`` override: keep the parameter, drop the CLI argument. + hidden: bool = False + + @property + def is_positional(self) -> bool: + """Is this a positional argument (as opposed to an option)? + + >>> ArgSpec('x', ['x']).is_positional + True + """ + return not self.flags[0].startswith("-") + + def update(self, other: "ArgSpec") -> None: + """Merge an override into this spec, by argh's rules (``argh/dto.py:33-50``). + + Four rules, none of which is ``dict.update``, and each of which is observable: + + * ``flags`` **append**: the inferred spellings survive and the declared ones are + added after them if new. This is why theremin's ``@arg('--synth', '-s')`` on a + parameter whose short flag was suppressed renders ``--synth [SYNTH], -s [SYNTH]`` + -- long first -- with no special case anywhere. + * ``required`` and ``default`` are taken only when the override *has* one. + * ``nargs`` is taken only when the override's is **truthy**, so there is no way to + unset an inferred ``nargs`` (argh has no spelling for it either; see ADR-0003). + * everything else is a plain ``dict.update``. + + >>> inferred = ArgSpec('synth', ['--synth'], default='sine') + >>> inferred.update(ArgSpec('synth', ['--synth', '-s'], nargs='?')) + >>> inferred.flags + ['--synth', '-s'] + """ + for flag in other.flags: + if flag not in self.flags: + self.flags.append(flag) + if other.required is not MISSING: + self.required = other.required + if other.default is not MISSING: + self.default = other.default + if other.nargs: + self.nargs = other.nargs + if other.codec is not None: + self.codec = other.codec + self.extra.update(other.extra) + + def add_argument_kwargs(self) -> Dict[str, Any]: + """The ``**kwargs`` half of the ``add_argument`` call. + + ``extra`` is applied last, which reproduces argh's + ``dict(kwargs, **other_add_parser_kwargs)`` -- including the quirk that a ``nargs`` + guessed from a list-valued default overrides an explicitly declared one. + """ + kwargs: Dict[str, Any] = {} + if self.required is not MISSING: + kwargs["required"] = self.required + if self.default is not MISSING: + kwargs["default"] = self.default + if self.nargs: + kwargs["nargs"] = self.nargs + return dict(kwargs, **self.extra) + + def add_argument_args(self) -> tuple: + """The full ``(args, kwargs)`` of the ``add_argument`` call this spec describes. + + One divergence from argh lives here and only here. argh registers a hyphenated + positional as ``add_argument('project-dir')``, producing a ``dest`` that literally + contains a hyphen, and then repairs it downstream. cw registers + ``add_argument('project_dir', metavar='project-dir')``, which renders identically + in ``usage:``, in ``--help`` and in argparse's error messages, and needs no repair. + + >>> ArgSpec('project_dir', ['project-dir']).add_argument_args() + (('project_dir',), {'metavar': 'project-dir'}) + """ + kwargs = self.add_argument_kwargs() + if self.is_positional: + kwargs.setdefault("metavar", self.flags[0]) + if kwargs["metavar"] == self.param_name: + del kwargs["metavar"] + return (self.param_name,), kwargs + return tuple(self.flags), kwargs + + @classmethod + def from_override(cls, param_name: str, override: Mapping[str, Any]) -> "ArgSpec": + """Build a spec from an override leaf -- ``add_argument`` kwargs plus cw's three. + + The three cw additions are ``flags`` (explicit option strings, hyphenated the way + argh hyphenates ``@arg``'s), ``codec`` (the post-parse decoder), and the whole + leaf being :data:`cw.HIDE`, which :func:`specs_for_function` handles before it + gets here. + + >>> ArgSpec.from_override('synth', {'flags': ['-s'], 'nargs': '?'}) + ArgSpec(param_name='synth', flags=['-s'], required=cw.MISSING, default=cw.MISSING, + nargs='?', extra={}, codec=None, hidden=False) + """ + if not isinstance(override, Mapping): + raise GrammarError( + f"the override for {param_name!r} must be a mapping of add_argument " + f"keyword arguments (or cw.HIDE), not {override!r}" + ) + rest = dict(override) + flags = [cli_name(flag) for flag in rest.pop("flags", ())] + codec = rest.pop("codec", None) + if codec is not None and not isinstance(codec, Codec): + codec = Codec(decode=codec) + spec = cls(param_name=param_name, flags=flags, codec=codec) + # argh's `make_from_kwargs` POPS these three out of the kwargs dict so that + # `update` can merge them by their own rules. Same here. + for key in FIELD_MERGED_KEYS: + if key in rest: + setattr(spec, key, rest.pop(key)) + spec.extra = rest + return spec + + +# -------------------------------------------------------------------------------------- +# Seam 1: decode -- a resolved type hint becomes add_argument kwargs + + +def argh_decode(param: inspect.Parameter, hint: Any) -> Mapping[str, Any]: + """Seam 1's default: argh's annotation guesser, if-branch for if-branch. + + A type implies more than a converter, which is why this returns a mapping of + ``add_argument`` kwargs rather than a callable: ``list[str]`` implies ``nargs='*'``, + ``Literal['a', 'b']`` implies ``choices``, ``Optional[int]`` implies + ``required=False``. The covered set is exactly argh's -- ``str``, ``int``, ``float``, + ``bool``, ``list``, ``list[T]``, ``Literal[...]`` and ``Union``/``Optional`` -- and + nothing else, because argh recognises nothing else. + + >>> p = inspect.Parameter('x', inspect.Parameter.KEYWORD_ONLY) + >>> argh_decode(p, int) + {'type': } + >>> argh_decode(p, typing.Literal['a', 'b']) + {'choices': ('a', 'b'), 'type': } + >>> argh_decode(p, list[int]) == {'nargs': '*', 'type': int} + True + >>> argh_decode(p, typing.Optional[int]) == {'type': int, 'required': False} + True + >>> argh_decode(p, dict) # not in argh's if-chain: no inference at all + {} + """ + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + if hint in BASIC_TYPES: + return {"type": hint} + if hint in (list, typing.List): + return {"nargs": ZERO_OR_MORE} + if origin is typing.Literal: + return {"choices": args, "type": type(args[0])} + if any(origin is union for union in _UNION_TYPES): + return _decode_union(args) + if origin is list: + guessed: Dict[str, Any] = {"nargs": ZERO_OR_MORE} + if args and args[0] in BASIC_TYPES: + guessed["type"] = args[0] + return guessed + return {} + + +def _decode_union(args: Sequence[Any]) -> Dict[str, Any]: + """argh's ``Union`` branch: the FIRST member decides, ``None`` makes it optional.""" + guessed: Dict[str, Any] = {} + first = args[0] + if first in BASIC_TYPES: + guessed["type"] = first + if first in (list, typing.List): + guessed["nargs"] = ZERO_OR_MORE + if first is not typing.List and typing.get_origin(first) is list: + guessed["nargs"] = ZERO_OR_MORE + item_args = typing.get_args(first) + if item_args and item_args[0] in BASIC_TYPES: + guessed["type"] = item_args[0] + if _NONE_TYPE in args: + guessed["required"] = False + return guessed + + +def modern_decode(param: inspect.Parameter, hint: Any) -> Mapping[str, Any]: + """Seam 1's shipped alternative: ``Optional[X]``, ``Enum`` and ``pathlib`` support. + + Composition is fall-through, not a registry: three ``if``\\ s and then + ``return argh_decode(param, hint)``. Nothing to register, nothing to order. + + ``Optional[X]`` is unwrapped to ``X`` rather than read as "the first member wins", + which is the single most useful difference: under argh, ``n: int | None = None`` + quietly becomes ``nargs='?'``. + + >>> p = inspect.Parameter('x', inspect.Parameter.KEYWORD_ONLY) + >>> modern_decode(p, typing.Optional[int]) == {'type': int} + True + >>> modern_decode(p, pathlib.Path) == {'type': pathlib.Path} + True + >>> class Colour(enum.Enum): + ... RED = 'r' + >>> decoded = modern_decode(p, Colour) + >>> decoded['type']('RED'), decoded['type']('r') + (, ) + """ + hint = _unwrap_optional(hint) + if isinstance(hint, type) and issubclass(hint, enum.Enum): + return {"type": _enum_by_name_then_value(hint), "choices": tuple(hint)} + if isinstance(hint, type) and issubclass(hint, pathlib.PurePath): + return {"type": pathlib.Path} + return argh_decode(param, hint) + + +def _unwrap_optional(hint: Any) -> Any: + """``Optional[X]`` -> ``X``; anything else unchanged.""" + if any(typing.get_origin(hint) is union for union in _UNION_TYPES): + rest = [arg for arg in typing.get_args(hint) if arg is not _NONE_TYPE] + if len(rest) == 1: + return rest[0] + return hint + + +def _enum_by_name_then_value(enum_class: type) -> Callable[[str], Any]: + """A ``type=`` converter reading an ``Enum`` by member name, then by value.""" + + def decode(token: str) -> Any: + try: + return enum_class[token] + except KeyError: + return enum_class(token) + + # argparse interpolates %(type)s from `type.__name__`, and the help formatter + # replaces any object having one; borrow the enum's so both read well. + decode.__name__ = enum_class.__name__ + return decode + + +def _as_add_argument_kwargs(decoded: Any) -> Dict[str, Any]: + """Normalise a ``decode`` return value: ``None`` | callable | Mapping -> kwargs.""" + if decoded is None: + return {} + if isinstance(decoded, Mapping): + return dict(decoded) + if callable(decoded): + return {"type": decoded} + raise GrammarError( + f"a decode function must return None, a callable or a mapping of add_argument " + f"keyword arguments; got {decoded!r}" + ) + + +# -------------------------------------------------------------------------------------- +# Naming + + +def cli_name(name: str, /, *, hyphenate: bool = True) -> str: + """The one name-mangling rule, applied to commands, groups, flags and config keys. + + ADR-0004 rule 2: derived names and the keys you address them by must go through the + *same* function, or a ``config`` written in one spelling silently misses a command + named in the other. + + >>> cli_name('git_ops'), cli_name('git_ops', hyphenate=False) + ('git-ops', 'git_ops') + """ + return name.replace("_", "-") if hyphenate else name + + +def command_name(func: Any, /, *, hyphenate: bool = True) -> str: + """The command word for a callable: its ``__name__``, with ``_`` becoming ``-``. + + >>> def pack_go(): ... + >>> command_name(pack_go) + 'pack-go' + + Anything without a ``__name__`` -- a ``functools.partial``, a callable instance -- + has no name to derive, and guessing one is how you ship the wrong CLI: + + >>> import functools + >>> command_name(functools.partial(pack_go)) + Traceback (most recent call last): + ... + TypeError: cannot derive a command name from functools.partial(...): it has no + __name__. Pass it explicitly with the mapping form: cw.dispatch({"my-command": obj}) + """ + name = getattr(func, "__name__", None) + if name is None: + raise TypeError( + f"cannot derive a command name from {func!r}: it has no __name__. " + 'Pass it explicitly with the mapping form: cw.dispatch({"my-command": obj})' + ) + return cli_name(name, hyphenate=hyphenate) + + +# -------------------------------------------------------------------------------------- +# Tier 1 + 2: the signature, and the hints + + +def _short_flag_collisions(signature: inspect.Signature) -> frozenset: + """First characters shared by two or more *named* parameters. + + argh's rule, and the one that surprises everyone: a collision suppresses the short + flag for **both** parameters rather than giving it to the first. That is why + ``wads pack populate_pkg_dir``'s 34 parameters have almost no short flags, and why it + is data rather than a heuristic -- adding a parameter can silently remove another + parameter's flag. + + Note the set is computed from every parameter that *could* be named (has a default, + or is keyword-only), regardless of whether the naming policy actually names it. + """ + named = [ + p.name + for p in signature.parameters.values() + if p.default is not p.empty or p.kind == p.KEYWORD_ONLY + ] + first_chars = [name[0] for name in named] + return frozenset(char for char in set(first_chars) if first_chars.count(char) > 1) + + +def _flag_spellings(param_name: str, collisions: frozenset, short_flags: bool) -> tuple: + """``('name-with-hyphens',), ('-n', '--name-with-hyphens')`` for one parameter.""" + hyphenated = cli_name(param_name) + positional = [hyphenated] + if short_flags and param_name[0] not in collisions: + options = [f"-{hyphenated[0]}", f"--{hyphenated}"] + else: + options = [f"--{hyphenated}"] + return positional, options + + +def _hints_of(func: Any, *, resolve: bool) -> Dict[str, Any]: + """The annotations a decode function will be shown. + + ``resolve=False`` is argh's behaviour: read ``__annotations__`` raw, which under + ``from __future__ import annotations`` means every hint is a *string* and therefore + matches nothing in the if-chain. That silently disables argh's coercion in roughly a + third of the fleet's annotated CLI files; it is reproduced, not fixed, because D2 says + improvements are opt-in (``convention=cw.MODERN`` resolves them). + """ + raw = dict(getattr(func, "__annotations__", None) or {}) + if not resolve: + return raw + try: + return typing.get_type_hints(func) + except (NameError, TypeError): + # An unresolvable forward reference. Degrade to argh's raw reading rather than + # failing to build a CLI over an annotation nobody asked us to resolve. + return raw + + +def infer_specs( + func: Any, + /, + *, + convention=None, + decode=None, + use_hints: bool = True, +) -> List[ArgSpec]: + """Tiers 1 and 2 of the ladder: the signature, then its type hints. + + The two argh code paths this replaces disagree about ``bool``, and argh patches the + disagreement twice, in two branches, with the same three lines. Here the annotation + result and the signature result meet in one place and the ``bool`` rule is stated + once: a hint of ``bool`` on a parameter with a ``bool`` default is dropped, because + ``type=bool`` and ``action='store_true'`` cannot both be passed to ``add_argument``. + + >>> def f(path, *, verbose: bool = False, tags: list = None): + ... ... + >>> [(s.param_name, s.flags, s.extra) for s in infer_specs(f)] + [('path', ['path'], {}), + ('verbose', ['-v', '--verbose'], {}), + ('tags', ['-t', '--tags'], {'nargs': '*'})] + """ + convention = convention if convention is not None else _default_convention() + decode = decode if decode is not None else convention.decode + signature = inspect.signature(func) + collisions = _short_flag_collisions(signature) + hints = _hints_of(func, resolve=convention.resolve_hints) if use_hints else {} + by_name = convention.naming == BY_NAME_IF_HAS_DEFAULT + specs: List[ArgSpec] = [] + + for param in signature.parameters.values(): + positional, options = _flag_spellings( + param.name, collisions, convention.short_flags + ) + default = param.default if param.default is not param.empty else MISSING + extra: Dict[str, Any] = {} + if param.name in hints: + extra = _as_add_argument_kwargs(decode(param, hints[param.name])) + + if param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + spec = ArgSpec(param.name, list(positional), default=default, extra=extra) + if default is not MISSING: + if by_name: + spec.flags = list(options) + else: + spec.nargs = OPTIONAL + if use_hints: + # `required=` is meaningless on a positional, so Optional[X]'s + # `required=False` is re-read as "an optional positional". + if spec.extra.pop("required", True) is False: + spec.nargs = OPTIONAL + if by_name: + _drop_redundant_bool_type(spec) + specs.append(spec) + + elif param.kind == param.KEYWORD_ONLY: + spec = ArgSpec(param.name, list(positional), default=default, extra=extra) + if by_name: + if default is not MISSING: + spec.flags = list(options) + else: + spec.flags = list(options) + if default is MISSING: + spec.required = True + if use_hints: + _drop_redundant_bool_type(spec) + specs.append(spec) + + elif param.kind == param.VAR_POSITIONAL: + specs.append( + ArgSpec(param.name, list(positional), nargs=ZERO_OR_MORE, extra=extra) + ) + + # VAR_KEYWORD contributes nothing at all. argh has no branch for it, so `**kwargs` + # is silently absent from the parser; cw reproduces the silence. Name the + # parameters you want on the command line, or hand them in through `config`. + + return specs + + +def _drop_redundant_bool_type(spec: ArgSpec) -> None: + """``type=bool`` + a ``bool`` default -> drop the type; ``store_true`` supersedes it.""" + if isinstance(spec.default, bool) and spec.extra.get("type") is bool: + del spec.extra["type"] + + +# -------------------------------------------------------------------------------------- +# The default-value guesser, and the finishing touches + + +def _guess_from_default(spec: ArgSpec) -> Dict[str, Any]: + """argh's second inference path: what the *default value* implies. + + Four rules, in argh's own order and with argh's own guards: + + ==================================== ================================= + a ``bool`` default, on an option ``store_false`` / ``store_true`` + a ``list``/``tuple`` default ``nargs='*'`` + any other non-``None`` default ``type=type(default)`` + a ``choices`` with no type yet ``type=type(choices[0])`` + ==================================== ================================= + + ``bool=True`` becoming ``store_false`` is the one everybody trips on: a parameter + that defaults to on gets a flag that turns it *off*, keeping its own name. It is + reproduced because a fleet of scripts is written against it. + """ + guessed: Dict[str, Any] = {} + extra = spec.extra + default = spec.default + + if default is not MISSING and default is not None: + if isinstance(default, bool): + # Not applicable to positionals: argparse's _StoreTrueAction takes no nargs. + if not spec.is_positional and extra.get("action") is None: + guessed["action"] = "store_false" if default else "store_true" + elif extra.get("type") is None: + if isinstance(default, (list, tuple)): + if "nargs" not in extra: + guessed["nargs"] = ZERO_OR_MORE + elif extra.get("action", "store") in ("store", "append"): + guessed["type"] = type(default) + + choices = extra.get("choices") + if choices and "type" not in list(guessed) + list(extra): + guessed["type"] = type(choices[0]) + return guessed + + +def finalise_spec( + spec: ArgSpec, /, *, parser_adds_help: bool = True, default_in_help: bool = True +) -> ArgSpec: + """The last three things argh does to every spec, in argh's order. + + 1. the default-value guesses land **over** the hint guesses (which is how a + ``nargs`` guessed from a list default beats an explicitly declared one -- argh's + quirk, reproduced); + 2. an argument with no help gets ``'%(default)s'``, which + :class:`cw.base.ArghHelpFormatter` renders as ``repr(default)``; + 3. ``-h`` is taken away from whoever inferred or declared it, because ``--help`` + owns it. A parameter named ``host`` never gets a short flag. + + >>> finalise_spec(ArgSpec('host', ['-h', '--host'], default='localhost')).flags + ['--host'] + """ + spec.extra.update(_guess_from_default(spec)) + if default_in_help and "help" not in spec.extra: + spec.extra["help"] = DFLT_HELP + if parser_adds_help and "-h" in spec.flags: + spec.flags = [flag for flag in spec.flags if flag != "-h"] + return spec + + +# -------------------------------------------------------------------------------------- +# The ladder + + +def specs_for_function( + func: Any, + /, + *, + convention=None, + config: Optional[Mapping[str, Any]] = None, + decode: Optional[Callable] = None, + parser_adds_help: bool = True, +) -> List[ArgSpec]: + """The whole grammar: one function in, its command-line arguments out. + + Four tiers, later wins, merged field-by-field per ADR-0003 (argh's + ``ParserAddArgumentSpec.update``, *not* ``dict.update``):: + + 1. signature inference kind, default, name, flag spellings + 2. hint inference decode(param, hint) + 3. function attribute func._cw['params'][param] (cw.compat.arg writes it) + 4. config config[param] (this call's particulars) + + Tier 2 is skipped entirely -- for *every* parameter of the function -- when tier 3 or + tier 4 is non-empty and ``convention.hints_when_declared`` is false. That is argh's + ``can_use_hints = not declared_args``: one override anywhere disables type inference + everywhere in that function. ADR-0003 extends "declared" to cover ``config`` too, so + that migrating an ``@argh.arg`` into a ``config`` entry does not silently switch hint + inference back on. + + A ``config`` leaf of :data:`cw.HIDE` removes the argument from the command line while + leaving the parameter to its own default: + + >>> def serve(host='0.0.0.0', port=8080, pool=None): + ... ... + >>> [s.flags for s in specs_for_function(serve, config={'pool': HIDE})] + [['--host'], ['--port']] + + (``--port`` and ``--pool`` share a first character, so neither gets a short flag, and + ``--host`` loses ``-h`` to ``--help``. Both rules are argh's.) + + A ``config`` key naming no parameter is an error, not a silent no-op -- unless the + function takes ``**kwargs``, in which case the argument is added and delivered there + (argh's rule): + + >>> specs_for_function(serve, config={'prot': {'help': 'typo'}}) + Traceback (most recent call last): + ... + cw.grammar.GrammarError: serve: override for 'prot' matches no parameter. This + function's parameters are: host, port, pool + """ + convention = convention if convention is not None else _default_convention() + declared = dict((getattr(func, "_cw", None) or {}).get("params", {})) + config = dict(config or {}) + + use_hints = convention.hints_when_declared or not (declared or config) + specs = infer_specs(func, convention=convention, decode=decode, use_hints=use_hints) + by_param = {spec.param_name: spec for spec in specs} + order = list(by_param) + + for tier in (declared, config): + for key, override in tier.items(): + param_name = key.replace("-", "_") + if override is HIDE: + if param_name not in by_param: + raise _no_such_parameter(func, key, by_param) + by_param[param_name].hidden = True + continue + over = ArgSpec.from_override(param_name, override) + if param_name in by_param: + _check_same_kind(func, by_param[param_name], over) + by_param[param_name].update(over) + elif _accepts_var_keyword(func): + if not over.flags: + over.flags = [f"--{cli_name(param_name)}"] + by_param[param_name] = over + order.append(param_name) + else: + raise _no_such_parameter(func, key, by_param) + + return [ + finalise_spec( + by_param[name], + parser_adds_help=parser_adds_help, + default_in_help=convention.default_in_help, + ) + for name in order + if not by_param[name].hidden + ] + + +def _accepts_var_keyword(func: Any) -> bool: + """Does ``func`` take ``**kwargs``? Asked only when an override matches no parameter, + so the common path inspects the signature exactly once.""" + return any( + p.kind == p.VAR_KEYWORD for p in inspect.signature(func).parameters.values() + ) + + +def _no_such_parameter(func, key, by_param) -> GrammarError: + known = ", ".join(by_param) or "(none)" + return GrammarError( + f"{getattr(func, '__name__', func)}: override for {key!r} matches no parameter. " + f"This function's parameters are: {known}" + ) + + +def _check_same_kind(func, inferred: ArgSpec, override: ArgSpec) -> None: + """An override may refine an argument; it may not turn a positional into an option.""" + if not override.flags or override.is_positional == inferred.is_positional: + return + kind = {True: "positional", False: "an option"} + raise GrammarError( + f"{getattr(func, '__name__', func)}: {inferred.param_name!r} is " + f"{kind[inferred.is_positional]} in the signature but " + f"{kind[override.is_positional]} in the override " + f"({', '.join(override.flags)}). Give the parameter a default value to make it " + f"an option, or drop the leading dashes to make it positional." + ) + + +def _default_convention(): + """``cw.ARGH``, imported late so that ``convention`` may import ``grammar``.""" + from cw.convention import ARGH + + return ARGH diff --git a/pyproject.toml b/pyproject.toml index 121b733..b8fd561 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,10 @@ dev = [ "pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.1.0", + # TEST ONLY, and deliberately not a runtime dependency: `tests/argh_parity` builds + # the same parser with argh and with cw and diffs them. cw itself never imports argh + # (LGPL-3.0-or-later; cw is MIT), and `tests/test_import_is_cheap.py` enforces that. + "argh==0.31.3", ] docs = [ "sphinx>=6.0", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/argh_parity/__init__.py b/tests/argh_parity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/argh_parity/corpus.py b/tests/argh_parity/corpus.py new file mode 100644 index 0000000..9f80e23 --- /dev/null +++ b/tests/argh_parity/corpus.py @@ -0,0 +1,299 @@ +"""One entry per argh behaviour cw promises to reproduce. + +Every function here exists to pin down a specific row of the D2 compatibility contract, and +its docstring says which. Cases are declared once and consumed twice -- by the action-table +diff and by the `--help` diff -- so adding a case costs one function and one `Case(...)`. + +Overrides are expressed twice per case, once for each library, and the two spellings are +asserted to be equivalent: + +* argh reads `@argh.arg(...)`, which writes `func.argh_args`; +* cw reads `func._cw['params']` (tier 3) or a `config` mapping (tier 4). `_cw` is written + as a plain attribute on purpose -- that is the documented contract, and it is what lets a + repo declare CLI details without importing cw at all. +""" + +import dataclasses +from typing import Any, Callable, Dict, List, Literal, Optional + +import argh +from argh.assembling import NameMappingPolicy + +from cw.convention import ARGH, MODERN, Convention + +BY_NAME_IF_HAS_DEFAULT = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT +BY_NAME_IF_KWONLY = NameMappingPolicy.BY_NAME_IF_KWONLY + + +@dataclasses.dataclass(frozen=True) +class Case: + """One function, plus everything both libraries need to build its parser.""" + + id: str + func: Callable + policy: NameMappingPolicy = BY_NAME_IF_HAS_DEFAULT + convention: Convention = ARGH + config: Optional[Dict[str, Any]] = None + + +def declare(*flags: str, **kwargs): + """Declare one argument for both libraries at once: `@argh.arg` and `func._cw`. + + The two must agree, since the point of the corpus is that they produce identical + parsers. Writing them from one call is what keeps them agreeing. + """ + + def decorate(func): + func = argh.arg(*flags, **kwargs)(func) + param = _param_name_of(flags) + params = dict(getattr(func, "_cw", {}).get("params", {})) + # Innermost decorator runs first but reads last, matching argh's insert(0, ...). + params = {param: dict(kwargs, flags=list(flags)), **params} + func._cw = {"params": params} + return func + + return decorate + + +def _param_name_of(flags): + """`('-i', '--ignore') -> 'ignore'`, argh's `naive_guess_func_arg_name`.""" + if len(flags) == 1: + return flags[0].lstrip("-").replace("-", "_") + for flag in flags: + if flag.startswith("--"): + return flag[2:].replace("-", "_") + raise ValueError(f"cannot guess a parameter name from {flags}") + + +# ====================================================================================== +# The functions. One per behaviour, docstrings naming the contract row. + + +def two_positionals(alpha, beta): + """Row 3, negative: no default means a positional, under either policy.""" + + +def defaults_become_options(alpha, beta=1, gamma="x"): + """Row 3: a defaulted POSITIONAL_OR_KEYWORD becomes an option.""" + + +def keyword_only(alpha, *, beta=1, gamma): + """Rows 3 + policy: kwonly with and without a default.""" + + +def bool_true_is_store_false(*, verbose: bool = True): + """Row 6, the footgun: `verbose=True` gives you a flag that turns it OFF.""" + + +def bool_false_is_store_true(*, verbose: bool = False): + """Row 6: `verbose=False` gives you `--verbose`.""" + + +def bool_untyped(*, verbose=False, quiet=True): + """Row 6 without annotations: the default value alone decides the action.""" + + +def short_flag_collisions(*, mango=1, node=2, snip=3, semantic=4, hybrid=5): + """Row 4: `s` collides so neither snip nor semantic gets one; `h` goes to --help.""" + + +def short_flag_h_alone(*, host="localhost"): + """Row 4: `h` is lost to --help even with nothing to collide with.""" + + +def list_default(*, scorer=()): + """Row 7: a tuple default implies `nargs='*'`.""" + + +def list_default_list(*, tags=[]): + """Row 7: so does a list default.""" + + +def typed_defaults(*, count=3, ratio=0.5, name="x", nothing=None): + """Row 8: a non-None default implies `type=type(default)`; None implies nothing.""" + + +def var_positional(*agents): + """Row 10: `*args` becomes a trailing positional with `nargs='*'`.""" + + +def var_positional_and_more(alpha, *rest, **configs): + """Rows 10 + 11 together: `*rest` appears, `**configs` does not.""" + + +def var_keyword_only(alpha, *, beta=1, **configs): + """Row 11: `**kwargs` contributes no CLI arguments at all.""" + + +def hyphenated_positional(project_dir, *, dry_run=False): + """Spec 9.3: the one permitted divergence, and it must stay invisible.""" + + +def hint_bare_list(project_dir, *, ignore: list = None): + """epythet's shape. A bare `list` annotation implies `nargs='*'`, so a flag with + zero values arrives as `[]` -- which every fleet docs job depends on.""" + + +def hint_list_of_str(project_dir, *, ignore: List[str] = None): + """`list[str]` implies both `nargs='*'` and `type=str`.""" + + +def hint_non_list_containers(*, a: tuple = None, b: dict = None, c: set = None): + """The negative of row 7: only `list` is in argh's if-chain. A `tuple` ANNOTATION + infers nothing, even though a `tuple` DEFAULT infers `nargs='*'`.""" + + +def hint_literal(*, fmt: Literal["json", "yaml"] = "json"): + """`Literal` implies `choices` and a type taken from the first member.""" + + +def hint_optional_int(*, retries: Optional[int] = None): + """`Optional[int]` implies `type=int` and `required=False`.""" + + +def hint_optional_positional(retries: Optional[int] = None): + """`Optional[int]`'s `required=False` is re-read as an optional positional.""" + + +def hint_string_annotation(path: str, *, to_version: "int | None" = None): + """Row 13: annotations are read raw, so a quoted one infers nothing at all.""" + + +def hint_int(*, n: int = 3): + """The plain case: an `int` annotation coerces.""" + + +@declare("--ignore", nargs="*") +def declared_disables_hints(project_dir, *, ignore: List[str] = None): + """Row 12: one override switches hint inference off for the WHOLE function, so + `ignore` keeps its `nargs` but loses `type=str`, and `project_dir` is untouched.""" + + +@declare("--level", choices=[1, 2, 3]) +def declared_choices(*, level=None): + """Row 9: `type` comes from `choices[0]` when nothing else supplied one.""" + + +@declare("--synth", "-s", nargs="?", const="list") +@declare("--scale", nargs="?", const="list", default=None) +def theremin_shape(synth="sine", scale=None, seconds=3): + """Rows 4 + 5 together, and issue #16's named acceptance case. + + `synth`, `scale` and `seconds` all start with `s`, so inference gives none of them a + short flag. `@arg('--synth', '-s')` then APPENDS `-s` after the inferred `--synth`, + which is why the help column reads `--synth [SYNTH], -s [SYNTH]` -- long first. No + code anywhere asks for that ordering; it falls out of the collision rule meeting the + append-merge rule. + """ + + +@declare("--xs", nargs="+") +def declared_nargs_loses_to_default(*, xs=[]): + """A quirk, not a feature: argh's default-value guesser runs AFTER the merge and + overwrites a declared `nargs='+'` with the `'*'` it guessed from the list default. + """ + + +@declare("agents", nargs=None, help="who to page") +def declared_falsy_nargs(*agents): + """ADR-0003: `if other.nargs:` -- a falsy override never unsets an inferred `nargs`, + so `*agents` keeps its `'*'`. A plain `dict.update` merge would silently drop it.""" + + +@declare("project", nargs="?", default=".", help="Project root") +def declared_optional_positional(project: str): + """coact's shape: a declared positional stays positional and gains `nargs='?'`.""" + + +@declare("--extra-thing", help="goes into **kwargs") +def declared_extra_into_kwargs(alpha, **kwargs): + """argh's rule: an override naming no parameter is legal iff `**kwargs` exists.""" + + +def many_parameters( + pkg_dir=".", + project_name="", + description="", + author="", + author_email="", + url="", + license_="mit", + keywords=(), + root_url="", + version="0.0.1", + long_description="", + display_name="", + verbose=False, + dry_run=False, + overwrite=False, + include_tests=True, + include_docs=True, + include_ci=True, + python_requires=">=3.8", + install_requires=(), + extras_require=(), + classifiers=(), + entry_points=(), + package_data=(), + exclude=(), + manifest="", + readme="", + changelog="", + gitignore="", + workflow="", + branch="master", + remote="origin", + token="", + quiet=False, +): + """`wads pack populate_pkg_dir`'s shape: 34 parameters, and collision suppression at + scale. Only the parameters whose first character is unique keep a short flag, which + makes the flag set a property of the whole signature rather than of any one + parameter.""" + + +CASES = [ + Case("two_positionals", two_positionals), + Case("defaults_become_options", defaults_become_options), + Case("keyword_only", keyword_only), + Case("bool_true_is_store_false", bool_true_is_store_false), + Case("bool_false_is_store_true", bool_false_is_store_true), + Case("bool_untyped", bool_untyped), + Case("short_flag_collisions", short_flag_collisions), + Case("short_flag_h_alone", short_flag_h_alone), + Case("list_default", list_default), + Case("list_default_list", list_default_list), + Case("typed_defaults", typed_defaults), + Case("var_positional", var_positional), + Case("var_positional_and_more", var_positional_and_more), + Case("var_keyword_only", var_keyword_only), + Case("hyphenated_positional", hyphenated_positional), + Case("hint_bare_list", hint_bare_list), + Case("hint_list_of_str", hint_list_of_str), + Case("hint_non_list_containers", hint_non_list_containers), + Case("hint_literal", hint_literal), + Case("hint_optional_int", hint_optional_int), + Case("hint_optional_positional", hint_optional_positional), + Case("hint_string_annotation", hint_string_annotation), + Case("hint_int", hint_int), + Case("declared_disables_hints", declared_disables_hints), + Case("declared_choices", declared_choices), + Case("theremin_shape", theremin_shape), + Case("declared_nargs_loses_to_default", declared_nargs_loses_to_default), + Case("declared_falsy_nargs", declared_falsy_nargs), + Case("declared_optional_positional", declared_optional_positional), + Case("declared_extra_into_kwargs", declared_extra_into_kwargs), + Case("many_parameters", many_parameters), +] + +#: The same corpus under argh's other name-mapping policy. `cw.BY_NAME_IF_KWONLY` must +#: track it just as exactly, or `convention=cw.MODERN` is not a supported thing to say. +KWONLY_CASES = [ + dataclasses.replace( + case, + id=f"{case.id}[kwonly]", + policy=BY_NAME_IF_KWONLY, + convention=dataclasses.replace(ARGH, naming=MODERN.naming), + ) + for case in CASES +] diff --git a/tests/argh_parity/harness.py b/tests/argh_parity/harness.py new file mode 100644 index 0000000..81b0fbd --- /dev/null +++ b/tests/argh_parity/harness.py @@ -0,0 +1,166 @@ +"""Build the same function's parser twice -- once with argh, once with cw -- and diff. + +This is the falsifiable half of D2. `cw.grammar` claims to reproduce argh 0.31.3's +signature inference; the only way to know is to ask argh, so `argh` is a **test-only** +dependency (the `dev` extra) and nothing under `cw/` imports it. + +Two comparisons run on every corpus case: + +* the **action table** -- every field of every `argparse.Action` the two parsers built, + in order; +* the rendered **`--help`** text, byte for byte, at a pinned terminal width. + +Exactly one divergence is normalised away, the one spec section 9.3 permits, and it is +invisible to a user: argh registers a hyphenated positional as +`add_argument('project-dir')`, giving a `dest` with a hyphen in it that no Python call can +use, then repairs it downstream; cw registers +`add_argument('project_dir', metavar='project-dir')`. `usage:`, `--help` and argparse's +error messages come out identical, and `normalise_action` compares the name the user sees. +Nothing else is forgiven -- `type`, `nargs`, `const`, `choices`, `default`, `required`, +`help` and the action class are compared as they are. +""" + +import argparse +import os +from typing import Any, Dict, List, Optional + +import argh +from argh.assembling import NameMappingPolicy + +import cw +from cw.convention import ARGH +from cw.grammar import specs_for_function + +#: argparse wraps help text to the terminal width; pin it so goldens are reproducible. +HELP_COLUMNS = "100" + +#: Every ``argparse.Action`` field the diff looks at. ``dest`` and ``metavar`` are in the +#: list on purpose: they are where the one permitted divergence shows up, and +#: :func:`normalise_action` is the only place that is allowed to forgive it. +ACTION_FIELDS = ( + "dest", + "option_strings", + "nargs", + "type", + "default", + "required", + "action_class", + "const", + "help", + "choices", + "metavar", +) + + +def argh_parser(func, *, policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, prog="prog"): + """The parser argh 0.31.3 builds for ``func``.""" + parser = argparse.ArgumentParser( + prog=prog, formatter_class=argh.PARSER_FORMATTER, description=func.__doc__ + ) + argh.set_default_command(parser, func, name_mapping_policy=policy) + return parser + + +def cw_parser(func, *, convention=ARGH, config=None, prog="prog"): + """The parser ``cw.grammar``'s specs describe for ``func``. + + ``cw.cli`` will do exactly this and a little more (subcommands, the ingress stash); + it does not exist yet, and the grammar is testable without it. + """ + parser = argparse.ArgumentParser( + prog=prog, formatter_class=cw.ArghHelpFormatter, description=func.__doc__ + ) + for spec in specs_for_function(func, convention=convention, config=config): + args, kwargs = spec.add_argument_args() + parser.add_argument(*args, **kwargs) + return parser + + +def normalise_action(action: argparse.Action) -> Dict[str, Any]: + """One action as a comparable dict, with the permitted divergences collapsed.""" + row = { + "dest": action.dest, + "option_strings": tuple(action.option_strings), + "nargs": action.nargs, + "type": getattr(action.type, "__name__", action.type), + "default": action.default, + "required": action.required, + "action_class": type(action).__name__, + "const": action.const, + "help": action.help, + "choices": action.choices, + "metavar": action.metavar, + } + if not action.option_strings: + # Spec 9.3: a positional's CLI name is `dest` in argh and `metavar` in cw. Compare + # the name the user actually sees, which is what both spellings produce. + row["dest"] = (action.metavar or action.dest).replace("_", "-") + row["metavar"] = None + return row + + +def action_table(parser: argparse.ArgumentParser) -> List[Dict[str, Any]]: + """Every action of ``parser``, normalised, in declaration order.""" + return [normalise_action(action) for action in parser._actions] + + +def render_help(parser: argparse.ArgumentParser) -> str: + """``parser.format_help()`` at a pinned width.""" + before = os.environ.get("COLUMNS") + os.environ["COLUMNS"] = HELP_COLUMNS + try: + return parser.format_help() + finally: + if before is None: + del os.environ["COLUMNS"] + else: + os.environ["COLUMNS"] = before + + +def diff_tables( + left: List[Dict[str, Any]], + right: List[Dict[str, Any]], + *, + ignore: Optional[set] = None, +) -> List[str]: + """Human-readable differences between two action tables, empty when identical.""" + ignore = ignore or set() + problems = [] + if len(left) != len(right): + problems.append( + f"different number of arguments: argh has {len(left)}, cw has {len(right)}\n" + f" argh: {[row['dest'] for row in left]}\n" + f" cw : {[row['dest'] for row in right]}" + ) + return problems + for a, b in zip(left, right): + for field in ACTION_FIELDS: + if field in ignore: + continue + if a[field] != b[field]: + problems.append( + f"{a['dest']}.{field}: argh={a[field]!r} cw={b[field]!r}" + ) + return problems + + +def build_both(case): + """Build both parsers, or report that both refused. + + A signature-and-override combination argh rejects (`@arg('--synth')` on a parameter + the policy makes positional) must be rejected by cw too. "Both refuse" is as much a + parity claim as "both produce this table", so it gets the same treatment rather than + being excluded from the corpus. + """ + left = _attempt(lambda: argh_parser(case.func, policy=case.policy)) + right = _attempt( + lambda: cw_parser(case.func, convention=case.convention, config=case.config) + ) + return left, right + + +def _attempt(build): + try: + return build(), None + except Exception as exc: # noqa: BLE001 - the exception IS the result here + return None, exc diff --git a/tests/argh_parity/test_parity.py b/tests/argh_parity/test_parity.py new file mode 100644 index 0000000..c8d6b21 --- /dev/null +++ b/tests/argh_parity/test_parity.py @@ -0,0 +1,192 @@ +"""The differential: cw's parser must equal argh's, action field by action field.""" + +import argparse + +import pytest + +from tests.argh_parity import corpus +from cw.grammar import GrammarError +from tests.argh_parity.harness import ( + action_table, + argh_parser, + build_both, + cw_parser, + diff_tables, + render_help, +) + +ALL_CASES = corpus.CASES + corpus.KWONLY_CASES + + +def _ids(cases): + return [case.id for case in cases] + + +@pytest.mark.parametrize("case", ALL_CASES, ids=_ids(ALL_CASES)) +def test_action_tables_are_identical(case): + """Every `add_argument` cw makes must match the one argh makes, field for field.""" + (left, left_error), (right, right_error) = build_both(case) + if left_error is not None: + refused_too = isinstance(right_error, GrammarError) + assert refused_too, f"argh refused {case.id} ({left_error}), cw built a parser" + return + assert right_error is None, f"cw refused {case.id}: {right_error}" + problems = diff_tables(action_table(left), action_table(right)) + assert not problems, "\n".join([f"case {case.id}:"] + problems) + + +@pytest.mark.parametrize("case", ALL_CASES, ids=_ids(ALL_CASES)) +def test_rendered_help_is_byte_identical(case): + """The user-visible surface: `--help`, character for character.""" + (left, left_error), (right, right_error) = build_both(case) + if left_error is not None: + pytest.skip("argh refuses this combination; covered by the action-table test") + assert render_help(right) == render_help(left) + + +@pytest.mark.parametrize("case", ALL_CASES, ids=_ids(ALL_CASES)) +def test_usage_lines_are_identical(case): + """`usage:` alone, called out because it is what a user sees on every error.""" + (left, left_error), (right, right_error) = build_both(case) + if left_error is not None: + pytest.skip("argh refuses this combination; covered by the action-table test") + assert right.format_usage() == left.format_usage() + + +# -------------------------------------------------------------------------------------- +# Tier 4 (`config`) must be interchangeable with tier 3 (`@arg` / `func._cw`) + + +CONFIG_EQUIVALENTS = [ + ( + "theremin_shape", + corpus.theremin_shape, + { + "synth": {"flags": ["--synth", "-s"], "nargs": "?", "const": "list"}, + "scale": { + "flags": ["--scale"], + "nargs": "?", + "const": "list", + "default": None, + }, + }, + ), + ( + "declared_choices", + corpus.declared_choices, + {"level": {"flags": ["--level"], "choices": [1, 2, 3]}}, + ), + ( + "declared_disables_hints", + corpus.declared_disables_hints, + {"ignore": {"flags": ["--ignore"], "nargs": "*"}}, + ), +] + + +@pytest.mark.parametrize( + "name,func,config", CONFIG_EQUIVALENTS, ids=[row[0] for row in CONFIG_EQUIVALENTS] +) +def test_config_is_equivalent_to_a_declaration(name, func, config): + """ADR-0003: moving an `@argh.arg` into `config` must change nothing. + + This is the migration every worked example in the spec performs, and it only holds + because `hints_when_declared` reads "declared" as tier 3 **or** tier 4. Drop the + `config` half of that condition and these cases regress silently. + """ + plain = _undecorated_twin(func) + from_declaration = action_table(cw_parser(func)) + from_config = action_table(cw_parser(plain, config=config)) + assert not diff_tables(from_declaration, from_config) + + +def _undecorated_twin(func): + """A copy of `func` with no `_cw` and no `argh_args`, so only `config` speaks.""" + import types + + twin = types.FunctionType( + func.__code__, + func.__globals__, + func.__name__, + func.__defaults__, + func.__closure__, + ) + twin.__kwdefaults__ = func.__kwdefaults__ + twin.__annotations__ = dict(func.__annotations__) + twin.__doc__ = func.__doc__ + return twin + + +# -------------------------------------------------------------------------------------- +# Behaviour, not just shape: what the parser actually produces from argv + + +PARSE_VECTORS = [ + ("hint_bare_list", corpus.hint_bare_list, [".", "--ignore"], {"ignore": []}), + ("hint_bare_list", corpus.hint_bare_list, ["."], {"ignore": None}), + ( + "hint_bare_list", + corpus.hint_bare_list, + [".", "--ignore", "a", "b"], + {"ignore": ["a", "b"]}, + ), + ("bool_true", corpus.bool_true_is_store_false, ["--verbose"], {"verbose": False}), + ("bool_true", corpus.bool_true_is_store_false, [], {"verbose": True}), + ("bool_false", corpus.bool_false_is_store_true, ["--verbose"], {"verbose": True}), + ("hint_int", corpus.hint_int, ["-n", "5"], {"n": 5}), + ("theremin_bare", corpus.theremin_shape, ["-s"], {"synth": "list"}), + ("theremin_value", corpus.theremin_shape, ["--synth", "bass"], {"synth": "bass"}), + ("varargs", corpus.var_positional, ["a", "b"], {"agents": ["a", "b"]}), + ("varargs_empty", corpus.var_positional, [], {"agents": []}), +] + + +@pytest.mark.parametrize( + "name,func,argv,expected", + PARSE_VECTORS, + ids=[f"{row[0]}:{'_'.join(row[2]) or 'bare'}" for row in PARSE_VECTORS], +) +def test_parsed_values_match_argh(name, func, argv, expected): + """The values reaching the function, which is the only surface a user can observe.""" + from_argh = vars(argh_parser(func).parse_args(argv)) + from_cw = vars(cw_parser(func).parse_args(argv)) + for key, value in expected.items(): + assert from_cw[key] == value, f"cw parsed {key}={from_cw[key]!r}" + assert from_argh[key] == value, f"argh parsed {key}={from_argh[key]!r}" + + +def test_hyphenated_positional_is_reachable_by_a_python_name(): + """The permitted divergence, stated as a property rather than hidden by the diff. + + argh's `dest` is `'project-dir'`, which no Python call can use; cw's is + `'project_dir'`, which is the parameter's own name. Both render `project-dir`. + """ + argh_ns = vars(argh_parser(corpus.hyphenated_positional).parse_args(["here"])) + argh_ns.pop("function") # argh stashes the endpoint in the namespace; cw does not + cw_ns = vars(cw_parser(corpus.hyphenated_positional).parse_args(["here"])) + assert argh_ns == {"project-dir": "here", "dry_run": False} + assert cw_ns == {"project_dir": "here", "dry_run": False} + assert "project-dir" in argh_parser(corpus.hyphenated_positional).format_usage() + assert "project-dir" in cw_parser(corpus.hyphenated_positional).format_usage() + + +def test_missing_positional_error_names_it_the_same_way(): + """The metavar has to survive into argparse's own error text, not just `--help`.""" + for parser in ( + argh_parser(corpus.hyphenated_positional), + cw_parser(corpus.hyphenated_positional), + ): + with pytest.raises(SystemExit): + parser.parse_args([]) + + +def test_every_case_builds_a_plain_argument_parser(): + """`mk_parser` returns a plain ArgumentParser; argcomplete is argparse-typed.""" + built = 0 + for case in ALL_CASES: + (_, _), (parser, error) = build_both(case) + if error is not None: + continue + assert type(parser) is argparse.ArgumentParser + built += 1 + assert built > len(ALL_CASES) - 5, f"only {built} of {len(ALL_CASES)} cases built" diff --git a/tests/test_grammar.py b/tests/test_grammar.py new file mode 100644 index 0000000..0e28a73 --- /dev/null +++ b/tests/test_grammar.py @@ -0,0 +1,531 @@ +"""`cw.grammar` on its own terms: the rules, the errors, and the named acceptance cases. + +The differential against live argh lives in `tests/argh_parity/`. This module tests the +things a differential cannot: that the errors are informative, that `cw.MODERN`'s +improvements exist and default OFF, and that the two acceptance cases issue #16 names by +hand come out right. +""" + +import dataclasses +import enum +import functools +import inspect +import pathlib +import typing + +import pytest + +from cw.base import HIDE, MISSING, Codec +from cw.convention import ARGH, MODERN, Convention +from cw.grammar import ( + ArgSpec, + GrammarError, + argh_decode, + cli_name, + command_name, + modern_decode, + specs_for_function, +) + + +def flags_of(func, **kwargs): + return [spec.flags for spec in specs_for_function(func, **kwargs)] + + +def kwargs_of(func, **kwargs): + return { + spec.param_name: spec.add_argument_kwargs() + for spec in specs_for_function(func, **kwargs) + } + + +# -------------------------------------------------------------------------------------- +# The two acceptance cases issue #16 names + + +def test_theremin_shape_renders_long_flag_first(): + """`--synth [SYNTH], -s [SYNTH]`, and `--scale` with no short flag at all. + + Both fall out of two rules meeting -- the collision rule suppresses every short flag + on a signature whose parameters all start with `s`, and the append-merge rule then + puts the explicitly declared `-s` AFTER the inferred `--synth`. Neither rule mentions + the other, and no code anywhere asks for this ordering. + """ + + def theremin(synth="sine", scale=None, seconds=3): ... + + config = { + "synth": {"flags": ["--synth", "-s"], "nargs": "?", "const": "list"}, + "scale": {"flags": ["--scale"], "nargs": "?", "const": "list"}, + } + assert flags_of(theremin, config=config) == [ + ["--synth", "-s"], + ["--scale"], + ["--seconds"], + ] + + +def test_collision_suppression_at_scale(): + """34 parameters, the `wads pack populate_pkg_dir` shape: most short flags vanish. + + The property that matters is not which flags survive but that survival is a fact + about the whole signature: adding one parameter can silently take another's short + flag away, which is why cw writes the rule as data rather than as a heuristic. + """ + from tests.argh_parity.corpus import many_parameters + + specs = specs_for_function(many_parameters) + with_short = [s.param_name for s in specs if len(s.flags) == 2] + assert len(specs) == 34 + assert with_short == [ + "url", + "keywords", + "overwrite", + "manifest", + "gitignore", + "workflow", + "branch", + "token", + "quiet", + ] + + +# -------------------------------------------------------------------------------------- +# The grammar rules, one test each + + +def test_bool_true_gives_store_false(): + def f(*, verbose=True, quiet=False): ... + + assert kwargs_of(f)["verbose"]["action"] == "store_false" + assert kwargs_of(f)["quiet"]["action"] == "store_true" + + +def test_h_is_always_lost_to_help(): + def f(*, host="localhost"): ... + + assert flags_of(f) == [["--host"]] + # ...unless the parser does not add --help itself; the flag is only taken away + # because something else already owns it. + assert flags_of(f, parser_adds_help=False) == [["-h", "--host"]] + + +def test_var_keyword_is_silently_dropped(): + def f(alpha, *, beta=1, **configs): ... + + assert [s.param_name for s in specs_for_function(f)] == ["alpha", "beta"] + + +def test_var_positional_is_a_trailing_nargs_star_positional(): + def f(*agents): ... + + (spec,) = specs_for_function(f) + assert spec.flags == ["agents"] + assert spec.add_argument_kwargs()["nargs"] == "*" + + +def test_bare_list_annotation_yields_nargs_star(): + """The load-bearing one: `epythet quickstart . --ignore` with zero values.""" + + def f(project_dir=".", ignore: list = None): ... + + assert kwargs_of(f)["ignore"]["nargs"] == "*" + + +def test_choices_supply_the_type(): + def f(*, level=None): ... + + assert ( + kwargs_of(f, config={"level": {"choices": [1, 2, 3]}})["level"]["type"] is int + ) + + +def test_hide_removes_the_argument_but_not_the_parameter(): + def f(host="0.0.0.0", pool=None): ... + + assert flags_of(f, config={"pool": HIDE}) == [["--host"]] + + +def test_one_override_disables_hints_for_the_whole_function(): + """argh's `can_use_hints = not declared_args`, extended to `config` by ADR-0003. + + `m` is annotated `float` but defaults to the int `4`, so the two inference paths + disagree about it and the test can see which one ran. + """ + + def f(*, n: float = 3, m: float = 4): ... + + assert kwargs_of(f)["m"]["type"] is float + with_override = kwargs_of(f, config={"n": {"help": "count"}}) + assert with_override["m"]["type"] is int # the default-value guesser, alone + assert with_override["m"]["default"] == 4 + # ...and MODERN keeps hints on, which is the whole point of the switch. + modern = kwargs_of(f, convention=MODERN, config={"n": {"help": "count"}}) + assert modern["m"]["type"] is float + + +def test_declared_nargs_loses_to_a_list_default(): + """An argh quirk, reproduced knowingly: the default-value guess runs last.""" + + def f(*, xs=[]): ... + + assert kwargs_of(f, config={"xs": {"nargs": "+"}})["xs"]["nargs"] == "*" + + +def test_falsy_nargs_in_an_override_is_ignored(): + """argh's `if other.nargs:` -- there is no spelling that unsets an inferred nargs. + + Both places an inferred `nargs` can live are covered: the `extra` dict (guessed from + a list default) and the `nargs` field itself (`*args`, and an optional positional + under `BY_NAME_IF_KWONLY`). A `dict.update` merge would silently drop the second. + """ + + def from_default(*, xs=[]): ... + + def from_var_positional(*agents): ... + + def from_optional_positional(x=1): ... + + assert kwargs_of(from_default, config={"xs": {"nargs": None}})["xs"]["nargs"] == "*" + override = {"agents": {"nargs": None}} + assert kwargs_of(from_var_positional, config=override)["agents"]["nargs"] == "*" + kwonly = dataclasses.replace(ARGH, naming="by_name_if_kwonly") + shape = kwargs_of( + from_optional_positional, convention=kwonly, config={"x": {"nargs": ""}} + ) + assert shape["x"]["nargs"] == "?" + + +def test_flags_append_they_do_not_replace(): + def f(*, ignore=None): ... + + assert flags_of(f, config={"ignore": {"flags": ["--skip"]}}) == [ + ["-i", "--ignore", "--skip"] + ] + + +def test_function_attribute_is_read_never_written(): + """Tier 3 is a plain attribute so a repo can declare CLI details without importing cw.""" + + def f(*, level=None): ... + + f._cw = {"params": {"level": {"choices": [1, 2, 3]}}} + before = dict(f._cw["params"]["level"]) + assert kwargs_of(f)["level"]["choices"] == [1, 2, 3] + assert f._cw["params"]["level"] == before + + +# -------------------------------------------------------------------------------------- +# Errors: informative, eager, and never silent + + +def test_a_config_key_naming_no_parameter_is_an_error(): + def serve(host="0.0.0.0", port=8080): ... + + with pytest.raises(GrammarError) as caught: + specs_for_function(serve, config={"prot": {"help": "typo"}}) + assert "matches no parameter" in str(caught.value) + assert "host, port" in str(caught.value) + + +def test_a_config_key_is_accepted_when_the_function_takes_kwargs(): + def serve(host="0.0.0.0", **extra): ... + + assert flags_of(serve, config={"anything": {"help": "h"}}) == [ + ["--host"], + ["--anything"], + ] + + +def test_turning_a_positional_into_an_option_is_an_error(): + def f(path): ... + + with pytest.raises(GrammarError) as caught: + specs_for_function(f, config={"path": {"flags": ["--path"]}}) + assert "positional in the signature" in str(caught.value) + + +def test_a_non_mapping_override_says_so(): + def f(path): ... + + with pytest.raises(GrammarError) as caught: + specs_for_function(f, config={"path": "help text"}) + assert "must be a mapping" in str(caught.value) + + +def test_command_name_without_a_name_says_what_to_do_instead(): + def pack_go(): ... + + assert command_name(pack_go) == "pack-go" + assert command_name(pack_go, hyphenate=False) == "pack_go" + with pytest.raises(TypeError) as caught: + command_name(functools.partial(pack_go)) + assert 'cw.dispatch({"my-command": obj})' in str(caught.value) + + +def test_a_decode_returning_nonsense_says_so(): + def f(*, n: int = 1): ... + + convention = dataclasses.replace(ARGH, decode=lambda param, hint: 42) + with pytest.raises(GrammarError) as caught: + specs_for_function(f, convention=convention) + assert "None, a callable or a mapping" in str(caught.value) + + +def test_a_decode_may_return_a_bare_callable(): + def f(*, n: "whatever" = 1): ... + + convention = dataclasses.replace(ARGH, decode=lambda param, hint: float) + assert kwargs_of(f, convention=convention)["n"]["type"] is float + + +# -------------------------------------------------------------------------------------- +# Seam 1: the two decode functions + + +PARAM = inspect.Parameter("x", inspect.Parameter.KEYWORD_ONLY) + + +@pytest.mark.parametrize( + "hint,expected", + [ + (str, {"type": str}), + (int, {"type": int}), + (float, {"type": float}), + (bool, {"type": bool}), + (list, {"nargs": "*"}), + (list[str], {"nargs": "*", "type": str}), + (typing.List[int], {"nargs": "*", "type": int}), + (typing.Literal["a", "b"], {"choices": ("a", "b"), "type": str}), + (typing.Optional[int], {"type": int, "required": False}), + (typing.Optional[list[int]], {"nargs": "*", "type": int, "required": False}), + (dict, {}), + ("int | None", {}), + (pathlib.Path, {}), + ], +) +def test_argh_decode_covers_exactly_arghs_if_chain(hint, expected): + assert argh_decode(PARAM, hint) == expected + + +class Colour(enum.Enum): + RED = "r" + BLUE = "b" + + +def test_modern_decode_unwraps_optional(): + assert modern_decode(PARAM, typing.Optional[int]) == {"type": int} + assert modern_decode(PARAM, typing.Optional[list[str]]) == { + "nargs": "*", + "type": str, + } + + +def test_modern_decode_reads_an_enum_by_name_then_value(): + decoded = modern_decode(PARAM, Colour) + assert decoded["choices"] == (Colour.RED, Colour.BLUE) + assert decoded["type"]("RED") is Colour.RED + assert decoded["type"]("b") is Colour.BLUE + with pytest.raises(ValueError): + decoded["type"]("green") + + +def test_modern_decode_maps_pure_paths_to_path(): + assert modern_decode(PARAM, pathlib.Path) == {"type": pathlib.Path} + assert modern_decode(PARAM, pathlib.PurePosixPath) == {"type": pathlib.Path} + + +def test_modern_decode_falls_through_to_argh_decode(): + assert modern_decode(PARAM, int) == argh_decode(PARAM, int) + + +def test_moderns_improvements_default_off(): + """D2: every improvement ships as a named `Convention` value, and defaults OFF.""" + + def f( + *, colour: Colour = Colour.RED, root: pathlib.Path = None, n: int | None = 1 + ): ... + + assert ARGH.decode is argh_decode + under_argh = kwargs_of(f) + assert "choices" not in under_argh["colour"] + assert "type" not in under_argh["root"] + assert under_argh["n"]["type"] is int and under_argh["n"]["required"] is False + under_modern = kwargs_of(f, convention=MODERN) + assert under_modern["colour"]["choices"] == (Colour.RED, Colour.BLUE) + assert under_modern["root"]["type"] is pathlib.Path + assert "required" not in under_modern["n"] + + +# -------------------------------------------------------------------------------------- +# The negative control: no dead switches + + +def _pep563_twin(func): + """`func` with its annotations left as strings, the way PEP 563 leaves them.""" + + def wrapper(*args, **kwargs): # pragma: no cover - never called + return func(*args, **kwargs) + + wrapper.__name__ = func.__name__ + wrapper.__signature__ = inspect.signature(func) + wrapper.__annotations__ = { + name: hint if isinstance(hint, str) else hint.__name__ + for name, hint in func.__annotations__.items() + } + return wrapper + + +def _switch_cases(): + """One representative function per `Convention` field, chosen to make it bite. + + Each function is picked so that the two inference paths *disagree* about it -- a + `float` annotation on a parameter defaulting to an int, an `Optional[int]` that only + `modern_decode` unwraps -- because a case where they agree cannot tell you which one + ran, and a negative control that cannot fail is not a control. + """ + + def naming(alpha, beta=1, *, gamma=2): ... + + def short_flags(*, verbose=False): ... + + def default_in_help(*, n=1): ... + + def disagreeing_hint(*, n: float = 1): ... + + def optional_hint(*, n: typing.Optional[int] = 1): ... + + return [ + ("naming", "by_name_if_kwonly", naming, None), + ("short_flags", False, short_flags, None), + ("default_in_help", False, default_in_help, None), + ("hints_when_declared", True, disagreeing_hint, {"n": {"help": "count"}}), + ("resolve_hints", True, _pep563_twin(disagreeing_hint), None), + ("decode", modern_decode, optional_hint, None), + ] + + +def _shape(func, **kwargs): + """Everything a switch could change: the flag spellings and the add_argument kwargs.""" + return [ + (spec.flags, spec.add_argument_kwargs()) + for spec in specs_for_function(func, **kwargs) + ] + + +@pytest.mark.parametrize( + "field,value,func,config", _switch_cases(), ids=[row[0] for row in _switch_cases()] +) +def test_every_convention_switch_changes_something(field, value, func, config): + """A field that changes nothing is speculative generality and does not ship.""" + baseline = _shape(func, config=config) + flipped = _shape( + func, convention=dataclasses.replace(ARGH, **{field: value}), config=config + ) + assert baseline != flipped, f"Convention.{field} changed nothing" + + +def test_hyphenate_switches_live_on_the_name_function(): + """`hyphenate_commands` / `hyphenate_groups` are read by `cli_name`, not by specs.""" + assert cli_name("git_ops") == "git-ops" + assert cli_name("git_ops", hyphenate=False) == "git_ops" + assert ARGH.hyphenate_commands is True and ARGH.hyphenate_groups is False + assert MODERN.hyphenate_commands is True and MODERN.hyphenate_groups is True + + +def test_convention_is_frozen_and_hashable(): + assert Convention() == ARGH + assert hash(Convention()) == hash(ARGH) + with pytest.raises(dataclasses.FrozenInstanceError): + ARGH.short_flags = False + + +# -------------------------------------------------------------------------------------- +# ArgSpec itself + + +def test_add_argument_args_repairs_a_hyphenated_positional(): + assert ArgSpec("project_dir", ["project-dir"]).add_argument_args() == ( + ("project_dir",), + {"metavar": "project-dir"}, + ) + assert ArgSpec("path", ["path"]).add_argument_args() == (("path",), {}) + + +def test_add_argument_args_leaves_an_explicit_metavar_alone(): + spec = ArgSpec("project_dir", ["project-dir"], extra={"metavar": "DIR"}) + assert spec.add_argument_args() == (("project_dir",), {"metavar": "DIR"}) + + +def test_missing_fields_are_simply_not_passed(): + assert ArgSpec("x", ["-x"]).add_argument_kwargs() == {} + assert ArgSpec("x", ["-x"], default=None).add_argument_kwargs() == {"default": None} + assert ArgSpec("x", ["-x"], default=MISSING).add_argument_kwargs() == {} + + +# -------------------------------------------------------------------------------------- +# The remaining merge rules and edge cases + + +def test_required_and_codec_come_through_the_merge(): + """`required` merges by its own rule, and `codec` rides along to the ingress site.""" + + def f(*, token=None): ... + + (spec,) = specs_for_function( + f, config={"token": {"required": True, "codec": str.upper}} + ) + assert spec.add_argument_kwargs()["required"] is True + assert isinstance(spec.codec, Codec) + assert spec.codec("abc") == "ABC" + # A Codec passed whole is kept as it is, passthrough and all. + (spec,) = specs_for_function( + f, config={"token": {"codec": Codec(decode=str.upper, passthrough={"list"})}} + ) + assert spec.codec("list") == "list" + + +def test_two_tiers_merge_in_order_and_hide_wins_from_either(): + """Tier 4 (`config`) lands on top of tier 3 (`func._cw`), field by field.""" + + def f(*, token=None, other=None): ... + + f._cw = {"params": {"token": {"help": "from _cw"}, "other": {"help": "kept"}}} + specs = {s.param_name: s for s in specs_for_function(f, config={"token": HIDE})} + assert set(specs) == {"other"} + assert specs["other"].add_argument_kwargs()["help"] == "kept" + + +def test_hiding_a_parameter_that_does_not_exist_is_an_error(): + def f(*, token=None): ... + + with pytest.raises(GrammarError) as caught: + specs_for_function(f, config={"nope": HIDE}) + assert "matches no parameter" in str(caught.value) + + +def test_a_union_whose_first_member_is_a_bare_list(): + assert argh_decode(PARAM, typing.Union[list, None]) == { + "nargs": "*", + "required": False, + } + + +def test_a_decode_returning_none_means_no_inference(): + def f(*, n: int = 1): ... + + convention = dataclasses.replace(ARGH, decode=lambda param, hint: None) + # The signature's own default still speaks; only the hint is silenced. + assert kwargs_of(f, convention=convention)["n"] == { + "default": 1, + "type": int, + "help": "%(default)s", + } + + +def test_an_unresolvable_annotation_falls_back_to_reading_it_raw(): + """`resolve_hints=True` must not turn a bad forward reference into a dead CLI.""" + + def f(*, thing: "NoSuchTypeAnywhere" = "x"): ... + + assert kwargs_of(f, convention=MODERN)["thing"]["type"] is str From 0358d89ca86b156002d1cdc34902ad8658c516d0 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:51:33 +0200 Subject: [PATCH 3/7] cw core: convention, commands, ingress, egress, cli and __main__ The rest of the core. `cw.dispatch` now builds and runs a real CLI, and 57 CLI-level differential cases say it is argh's CLI: exit code, stdout and stderr byte-identical over subcommands, groups, every result shape and every error shape. refs #17 #18 #19 #20 #21 #24 - convention (#17): `egress` joins `decode` as a field, so flipping to `cw.MODERN` stays one act; `MODERN` keeps `default_in_help=True` per ADR-0004 rule 4. The merge ladder is documented here and implemented once, in `grammar.specs_for_function`. No second copy, no argparse. - commands (#18): the six forms, discriminated by value. A mapping key or `__all__` entry beats `__name__` and is then hyphenated -- the one rule under which xa's `gen-secret`, `list` and priv's `parse_pth_paths` all come out right, and which gives priv's `functools.partial` the command name argh gets wrong. A list of strings is refused with both working spellings; a factory is not called for you. - ingress (#19): every parameter kind, `i2.wrapper.Ingress`'s call contract, and no i2. `*args` extends rather than re-packs, `**kwargs` collects leftovers, and a `cw.HIDE`d parameter falls back to the function's own default -- so priv loses `--config-type` from the CLI and still receives `'setup.cfg'`. - egress (#20): argh's type whitelist as the default, the Iterable protocol as MODERN's, JSON, `write_lines` and `confirm`. Streams resolve at CALL time, which is what makes a cw CLI testable with capsys and an argh CLI not. A coroutine is refused at the call site, so no choice of egress can switch the check off. - cli (#21): `mk_parser` returns a plain `ArgumentParser` and does no I/O; everything `run` needs travels in one reserved `set_defaults` key, and a parameter of that name is an error naming it. `run` gains `convention=` and `config=` (ADR-0002) and resolves them from the subcommand's own stash. `group_kwargs` reaches `add_subparsers` whole and `title` reaches the parent row, `help=` passed even as None (ADR-0004 rule 1). A config key naming no command is a startup error, which is what closes the MODERN group-rename trap. Both `group_name=` and argh's dead `namespace=` are accepted. - __main__ + completion (#24): `python -m cw specs|help|parity`, built with cw. Completion fires at dispatch time, never in `mk_parser`, and argcomplete is imported inside the function. Also deletes the empty `cw/util.py` (docstring only, zero importers, flagged by both previous phases). Verified, not asserted: the COMP_LINE protocol round-trips against a built parser at both levels; 18 deliberate mutations each produce failures; 100% statement coverage on every module in this phase. --- cw/__init__.py | 51 +- cw/__main__.py | 94 ++++ cw/cli.py | 697 +++++++++++++++++++++++++++ cw/commands.py | 260 ++++++++++ cw/convention.py | 46 +- cw/egress.py | 334 +++++++++++++ cw/ingress.py | 182 +++++++ cw/util.py | 1 - tests/argh_parity/test_cli_parity.py | 411 ++++++++++++++++ tests/test_cli.py | 463 ++++++++++++++++++ tests/test_commands.py | 183 +++++++ tests/test_convention.py | 160 ++++++ tests/test_egress.py | 251 ++++++++++ tests/test_fleet_shapes.py | 204 ++++++++ tests/test_ingress.py | 193 ++++++++ tests/test_main.py | 95 ++++ 16 files changed, 3613 insertions(+), 12 deletions(-) create mode 100644 cw/__main__.py create mode 100644 cw/cli.py create mode 100644 cw/commands.py create mode 100644 cw/egress.py create mode 100644 cw/ingress.py delete mode 100644 cw/util.py create mode 100644 tests/argh_parity/test_cli_parity.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_convention.py create mode 100644 tests/test_egress.py create mode 100644 tests/test_fleet_shapes.py create mode 100644 tests/test_ingress.py create mode 100644 tests/test_main.py diff --git a/cw/__init__.py b/cw/__init__.py index ce640c3..e1f7f84 100644 --- a/cw/__init__.py +++ b/cw/__init__.py @@ -27,9 +27,21 @@ (``pip install 'cw[resource]'``). ``tests/test_import_is_cheap.py`` asserts this in a fresh subprocess, which is the only place the claim can honestly be checked. ->>> import cw ->>> cw.CommandError('no such pipeline', code=2).code -2 +>>> import cw, io +>>> def greet(name, *, loudly=False): +... '''Say hello to someone.''' +... return f'HELLO {name}' if loudly else f'hello {name}' +>>> out = io.StringIO() +>>> cw.dispatch(greet, ['world', '--loudly'], out=out) +0 +>>> out.getvalue() +'HELLO world\\n' + +The same command as a function, for a test that does not want a CLI at all: + +>>> cw.dispatch(greet, ['world'], standalone=False) +'hello world' + >>> cw.resolve_to_function('builtins.len') is len True """ @@ -43,6 +55,15 @@ HIDE, MISSING, ) +from cw.cli import ( + add_commands, + dispatch, + enable_completion, + mk_parser, + run, + set_default_command, +) +from cw.commands import commands_from from cw.convention import ( ARGH, BY_NAME_IF_HAS_DEFAULT, @@ -50,9 +71,17 @@ MODERN, Convention, ) +from cw.egress import ( + argh_egress, + confirm, + iterable_egress, + json_egress, + write_lines, +) from cw.grammar import ( GrammarError, argh_decode, + cli_name, command_name, modern_decode, ) @@ -75,11 +104,27 @@ "Egress", "HIDE", "MISSING", + # -- cli: building a parser, and running one ---------------------------------------- + "add_commands", + "dispatch", + "enable_completion", + "mk_parser", + "run", + "set_default_command", + # -- commands: an object becomes a {name: callable} tree ----------------------------- + "commands_from", # -- grammar: a signature becomes command-line arguments ---------------------------- "GrammarError", "argh_decode", + "cli_name", "command_name", "modern_decode", + # -- egress: a return value becomes lines and an exit code --------------------------- + "argh_egress", + "confirm", + "iterable_egress", + "json_egress", + "write_lines", # -- convention: what the defaults ARE ---------------------------------------------- "ARGH", "BY_NAME_IF_HAS_DEFAULT", diff --git a/cw/__main__.py b/cw/__main__.py new file mode 100644 index 0000000..a1554c7 --- /dev/null +++ b/cw/__main__.py @@ -0,0 +1,94 @@ +"""cw's own CLI, built with cw. + +``python -m cw`` is dogfood: every command below is a plain function, and the parser that +serves them is :func:`cw.dispatch` on a mapping. If cw could not build its own CLI +comfortably, that would be worth knowing before 66 fleet console scripts find out. + +Three commands:: + + python -m cw specs 'pkg.mod:func' # the command-line arguments cw would build + python -m cw help 'pkg.mod:func' # the --help cw would print for it + python -m cw parity # the one-command migration test + +``specs`` is the one that earns its place day to day: it answers "what flags does this +function actually get, and why did that one not get a short flag" without building, running +or importing anybody's ``__main__``. +""" + +import sys + +from cw.commands import import_object +from cw.convention import ARGH, MODERN +from cw.grammar import specs_for_function + +__all__ = ["specs", "help_for", "parity", "COMMANDS"] + +#: The conventions ``--convention`` accepts, by the name you type. +CONVENTIONS = {"argh": ARGH, "modern": MODERN} + + +def _convention_named(name: str): + """The :class:`cw.Convention` called ``name``, or a message naming the real ones.""" + try: + return CONVENTIONS[name] + except KeyError: + from cw.base import CommandError + + raise CommandError( + f"no convention named {name!r}. Choose one of: {', '.join(CONVENTIONS)}." + ) from None + + +def specs(ref, *, convention="argh"): + """Show the command-line arguments cw would build for a function. + + REF is a 'pkg.mod:name' reference, e.g. 'json:dumps'. + """ + func = import_object(ref) + for spec in specs_for_function(func, convention=_convention_named(convention)): + args, kwargs = spec.add_argument_args() + shown = ", ".join(f"{key}={value!r}" for key, value in sorted(kwargs.items())) + yield f"{' '.join(args):24s} {shown}" + + +def help_for(ref, *, convention="argh"): + """Print the --help that cw would produce for a function. + + REF is a 'pkg.mod:name' reference, e.g. 'json:dumps'. + """ + from cw.cli import mk_parser + + parser = mk_parser( + ref, convention=_convention_named(convention), prog=ref.rpartition(":")[2] + ) + return parser.format_help() + + +def parity(goldens_dir=None): + """Run the recorded-golden migration test (cw.testing). + + Its result is an exit code rather than something to print, so it is raised rather than + returned -- `SystemExit` is how a command says "this is the process's answer". + """ + try: + from cw import testing + except ImportError as exc: + from cw.base import CommandError + + raise CommandError(f"cw.testing is not available: {exc}") from exc + raise SystemExit(testing.parity(goldens_dir)) + + +#: ``python -m cw``'s command tree. A plain mapping, exactly like a fleet repo's. +COMMANDS = {"specs": specs, "help": help_for, "parity": parity} + + +def main(argv=None) -> int: + """The ``python -m cw`` entry point.""" + from cw.cli import dispatch + + return dispatch(COMMANDS, argv, prog="cw", description=__doc__.split("\n\n")[0]) + + +if __name__ == "__main__": # pragma: no cover - exercised as a subprocess + sys.exit(main()) diff --git a/cw/cli.py b/cw/cli.py new file mode 100644 index 0000000..367d736 --- /dev/null +++ b/cw/cli.py @@ -0,0 +1,697 @@ +"""The argparse surface: building a parser, and running one. + +Three functions, and the third is the composition of the first two:: + + parser = cw.mk_parser(obj, ...) # build. Pure: no parsing, no I/O. + code = cw.run(parser, argv, ...) # parse, call, print, return an exit code. + code = cw.dispatch(obj, argv, ...) # == run(mk_parser(obj)) + +:func:`mk_parser` returns a **plain** :class:`argparse.ArgumentParser`, never a subclass. +That is load-bearing rather than tasteful: ``argcomplete.autocomplete(argument_parser: +argparse.ArgumentParser, ...)`` is argparse-typed at its signature, so eight fleet repos and +ten ``# PYTHON_ARGCOMPLETE_OK`` markers survive the migration untouched -- which the one +fleet repo that moved to ``typer`` could not manage. + +Because the parser is plain, cw cannot keep per-subcommand state on it as an attribute. +It travels instead in one reserved ``set_defaults`` key, :data:`RESERVED_DEST`, which +carries the target function, its ingress and the convention that built it. A parameter of +that name is an error naming the collision, never silent corruption. + +>>> import cw, io +>>> def greet(name, *, loudly=False): +... '''Say hello.''' +... return f'HELLO {name}' if loudly else f'hello {name}' +>>> out = io.StringIO() +>>> cw.dispatch(greet, ['world'], out=out) +0 +>>> out.getvalue() +'hello world\\n' + +``standalone=False`` turns the same call into an ordinary function call -- nothing is +printed, nothing is caught, and you get the function's own return value: + +>>> cw.dispatch(greet, ['world', '--loudly'], standalone=False) +'HELLO world' +""" + +import argparse +import contextlib +import dataclasses +import functools +import inspect +import sys +import warnings +from collections.abc import Mapping +from typing import Any, Callable, Optional, TextIO + +from cw.base import ArghHelpFormatter, CommandError +from cw.commands import commands_from, import_object, is_command +from cw.convention import ARGH, Convention +from cw.egress import guard_no_coroutine +from cw.grammar import GrammarError, specs_for_function +from cw.ingress import mk_ingress + +__all__ = [ + "mk_parser", + "run", + "dispatch", + "add_commands", + "set_default_command", + "enable_completion", + "RESERVED_DEST", +] + +#: The one ``set_defaults`` key cw reserves on the parsers it builds. Everything a built +#: parser needs to tell :func:`run` -- which function this subcommand is, how to call it, +#: and under which convention -- travels in it. +RESERVED_DEST = "_cw" + +#: The exit code argparse itself uses for a usage error, and therefore the one :func:`run` +#: reports when it catches one. +USAGE_ERROR_CODE = 2 + + +@dataclasses.dataclass(frozen=True) +class _Stash: + """What a built parser tells :func:`run`, carried in one namespace attribute. + + ``func`` is ``None`` on a parser that only holds subcommands: reaching :func:`run` with + that still set means no subcommand was chosen, which is the usage-line case. + """ + + convention: Convention + func: Optional[Callable] = None + ingress: Optional[Callable] = None + config: Optional[Mapping] = None + + +# -------------------------------------------------------------------------------------- +# Building + + +def _new_parser(convention: Convention, parser_kwargs: dict) -> argparse.ArgumentParser: + """An ``ArgumentParser`` with cw's default look, and a readable error for a typo. + + ``prog``, ``description``, ``epilog``, ``formatter_class``, ``allow_abbrev`` and + friends pass through verbatim -- cw invents no vocabulary for anything argparse already + names -- so a misspelling would otherwise surface as a bare ``ArgumentParser.__init__`` + ``TypeError`` that never mentions cw. + """ + parser_kwargs.setdefault("formatter_class", ArghHelpFormatter) + try: + return argparse.ArgumentParser(**parser_kwargs) + except TypeError as exc: + accepted = ", ".join( + name + for name in inspect.signature(argparse.ArgumentParser).parameters + if name != "self" + ) + raise TypeError( + f"cw passes unknown keyword arguments straight to " + f"argparse.ArgumentParser, and it rejected one: {exc}. " + f"argparse.ArgumentParser accepts: {accepted}." + ) from exc + + +def _warn_bound_keywords(func: Any, specs) -> None: + """Warn about a ``functools.partial``'s pre-bound keyword that is still a CLI flag. + + :func:`inspect.signature` keeps a partial's bound keyword (Python moves it to + keyword-only), so cw renders it as an option unless told not to. cw does **not** + auto-hide it: ``inspect.signature`` is cw's single source of truth for everything else, + and silently diverging from it would be a second, subtler bug than the one being fixed. + A warning names the leak and the one line that closes it. + + It reads the finished ``specs`` rather than the signature, so that hiding the keyword + silences the warning -- a warning you cannot act on is noise. + """ + if not isinstance(func, functools.partial): + return + bound = set(func.keywords or ()) + for spec in specs: + if spec.param_name in bound: + warnings.warn( + f"{func!r}: the pre-bound keyword {spec.param_name!r} is still exposed " + f"as a command-line option. Hide it with " + f"config={{'': {{{spec.param_name!r}: cw.HIDE}}}}", + UserWarning, + stacklevel=4, + ) + + +def set_default_command( + parser: argparse.ArgumentParser, + func: Callable, + /, + *, + config: Optional[Mapping] = None, + convention: Convention = ARGH, +) -> argparse.ArgumentParser: + """Bind one function to ``parser``: add its arguments, and stash how to call it. + + This is what makes ``run``'s reason for existing true -- a repo can hand-build a + parser, ``add_argument`` to it, bind a function here, and still get cw's ingress and + egress: + + >>> import argparse, cw, io + >>> from cw.cli import set_default_command + >>> parser = argparse.ArgumentParser(prog='count') + >>> def tally(word, *, times=1): + ... return [word] * times + >>> _ = set_default_command(parser, tally) + >>> out = io.StringIO() + >>> cw.run(parser, ['hi', '-t', '2'], out=out) + 0 + >>> out.getvalue() + 'hi\\nhi\\n' + + The function's docstring becomes the parser's description, unless the parser already + has one -- argh's rule, and it is what lets ``description=`` override it. + """ + specs = specs_for_function( + func, convention=convention, config=config, parser_adds_help=parser.add_help + ) + _warn_bound_keywords(func, specs) + for spec in specs: + if spec.param_name == RESERVED_DEST: + raise GrammarError( + f"{getattr(func, '__name__', func)}: the parameter {RESERVED_DEST!r} " + "collides with the namespace key cw reserves for its own use. Rename the " + "parameter, or hide it with cw.HIDE." + ) + args, kwargs = spec.add_argument_args() + try: + parser.add_argument(*args, **kwargs) + except argparse.ArgumentError as exc: + raise GrammarError( + f"{getattr(func, '__name__', func)}: cannot add {spec.param_name!r} as " + f"{'/'.join(spec.flags)}: {exc}" + ) from exc + if not parser.description: + parser.description = inspect.getdoc(func) + codecs = {spec.param_name: spec.codec for spec in specs if spec.codec is not None} + _stash( + parser, + _Stash( + convention=convention, + func=func, + ingress=mk_ingress(func, codecs=codecs), + config=config, + ), + ) + return parser + + +def _stash(parser: argparse.ArgumentParser, stash: _Stash) -> None: + parser.set_defaults(**{RESERVED_DEST: stash}) + + +def _subparsers_of(parser: argparse.ArgumentParser) -> argparse.Action: + """The parser's subparsers action, created on first use. + + ``add_subparsers`` may be called only once per parser, so a second ``add_commands`` + against the same parser has to find the first one's. + """ + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + return action + return parser.add_subparsers() + + +def _add_command( + subparsers: argparse.Action, + name: str, + func: Callable, + *, + config, + convention, + formatter_class, +) -> None: + """One subcommand. Its listing row is its raw ``__doc__``, which is argh's choice.""" + command_parser = subparsers.add_parser( + name, help=func.__doc__, formatter_class=formatter_class + ) + set_default_command(command_parser, func, config=config, convention=convention) + + +def _add_group( + subparsers: argparse.Action, + name: str, + members: Mapping, + *, + config, + convention, + formatter_class, + group_kwargs=None, +) -> None: + """One group of subcommands -- the only level of nesting there is. + + Two details, both of which a plausible implementation gets wrong and neither of which + is visible until someone reads ``--help``: + + * the **parent's listing row** reads ``group_kwargs['title']``, not ``['help']``; and + * ``help=`` must be passed to ``add_parser`` **even when it is None**, because that is + what makes argparse create the pseudo-action -- omit it and the group vanishes from + the parent's ``--help`` entirely. + + The whole ``group_kwargs`` mapping then goes to ``add_subparsers``, ``help`` included, + where it renders inside the group's own ``--help``. Nothing is dropped. + """ + group_kwargs = dict(group_kwargs or {}) + holder = subparsers.add_parser( + name, help=group_kwargs.get("title"), formatter_class=formatter_class + ) + inner = holder.add_subparsers(**group_kwargs) + _stash(holder, _Stash(convention=convention)) + _add_tree( + holder, + members, + config=config, + convention=convention, + formatter_class=formatter_class, + subparsers=inner, + ) + + +def _check_config_keys(tree: Mapping, config: Mapping, *, what: str) -> None: + """A ``config`` key naming no command is a startup error, never a silent no-op. + + This is the rule that closes the trap where ``convention=cw.MODERN`` renames a group + (``hyphenate_groups``) and every config entry keyed by the old name quietly stops + applying. Keys and names go through one naming function, so a mismatch is a bug, and a + bug should be loud. + """ + unknown = [key for key in config if key not in tree] + if unknown: + known = ", ".join(tree) or "(none)" + raise GrammarError( + f"config key{'s' if len(unknown) > 1 else ''} " + f"{', '.join(repr(key) for key in unknown)} match no {what}. " + f"The {what}s are: {known}. Note that names are hyphenated by the " + "convention, so a config must be keyed the way the command line is typed." + ) + + +def _add_tree( + parser: argparse.ArgumentParser, + tree: Mapping, + *, + config: Mapping, + convention: Convention, + formatter_class, + subparsers: Optional[argparse.Action] = None, +) -> None: + """Add every command and group in ``tree`` to ``parser``'s subparsers.""" + _check_config_keys(tree, config, what="command or group") + subparsers = _subparsers_of(parser) if subparsers is None else subparsers + for name, value in tree.items(): + adder = _add_command if is_command(value) else _add_group + adder( + subparsers, + name, + value, + config=config.get(name) or {}, + convention=convention, + formatter_class=formatter_class, + ) + + +def _with_decode(convention: Convention, decode) -> Convention: + """``convention``, or a copy of it carrying an explicitly passed ``decode=``.""" + return ( + convention if decode is None else dataclasses.replace(convention, decode=decode) + ) + + +def mk_parser( + obj: Any, + /, + *, + config: Optional[Mapping] = None, + convention: Convention = ARGH, + decode=None, + **parser_kwargs, +) -> argparse.ArgumentParser: + """Build the parser ``obj`` describes. No parsing, no I/O, no side effects. + + Args: + obj: A callable, a list, a mapping, a module, or a ``'pkg.mod:name'`` reference -- + see :mod:`cw.commands` for what each one means. + config: This call's particulars, keyed the way you address the thing on the command + line: ``{param: add_argument_kwargs}`` for a single command, + ``{command: {param: ...}}`` for several, nested once more for a group. + convention: What the defaults ARE. :data:`cw.ARGH` by default. + decode: Seam 1, overriding ``convention.decode`` when given. + parser_kwargs: Passed verbatim to :class:`argparse.ArgumentParser` -- + ``prog``, ``description``, ``epilog``, ``formatter_class``, ``allow_abbrev``. + + Returns: + A plain :class:`argparse.ArgumentParser`. Not a subclass -- see the module + docstring for why that is load-bearing. + + >>> import argparse, cw + >>> def ls(path='.'): ... + >>> type(cw.mk_parser(ls)) is argparse.ArgumentParser + True + + A single callable is one command with no command word; anything else is subcommands: + + >>> cw.mk_parser(ls, prog='x').format_usage() + 'usage: x [-h] [-p PATH]\\n' + >>> cw.mk_parser([ls], prog='x').format_usage() + 'usage: x [-h] {ls} ...\\n' + + Completion is **not** fired here. :func:`argcomplete.autocomplete` reads the + environment and may exit the process, and ``mk_parser`` is what a test inspects; so + completion happens in :func:`run`, which is also where argh does it. + """ + convention = _with_decode(convention, decode) + if isinstance(obj, str): + obj = import_object(obj) + parser = _new_parser(convention, parser_kwargs) + if is_command(obj): + set_default_command(parser, obj, config=config, convention=convention) + else: + _stash(parser, _Stash(convention=convention)) + _add_tree( + parser, + commands_from(obj, convention=convention), + config=config or {}, + convention=convention, + formatter_class=parser.formatter_class, + ) + return parser + + +def add_commands( + parser: argparse.ArgumentParser, + obj: Any, + /, + *, + group_name: Optional[str] = None, + namespace: Optional[str] = None, + group_kwargs: Optional[Mapping] = None, + namespace_kwargs: Optional[Mapping] = None, + config: Optional[Mapping] = None, + convention: Convention = ARGH, + decode=None, +) -> argparse.ArgumentParser: + """Add ``obj``'s commands to an existing parser, optionally under one group. + + ``namespace=`` and ``namespace_kwargs=`` are argh's pre-0.30 spellings of + ``group_name=`` and ``group_kwargs=``. argh renamed them with no alias, which left four + fleet call sites passing a keyword nothing reads; accepting both revives them. + + When ``group_name`` is given, ``config`` is keyed by *command* name -- ``config`` always + has the same shape as the ``obj`` beside it, and here that ``obj`` is the group's + members. + + >>> import argparse, cw + >>> from cw.cli import add_commands + >>> def status(): ... + >>> parser = argparse.ArgumentParser(prog='priv') + >>> _ = add_commands(parser, [status], group_name='git_ops') + >>> parser.format_usage() + 'usage: priv [-h] {git_ops} ...\\n' + """ + convention = _with_decode(convention, decode) + group_name = group_name if group_name is not None else namespace + group_kwargs = group_kwargs if group_kwargs is not None else namespace_kwargs + tree = commands_from(obj, convention=convention) + config = dict(config or {}) + if parser.get_default(RESERVED_DEST) is None: + _stash(parser, _Stash(convention=convention)) + if group_name is None: + _add_tree( + parser, + tree, + config=config, + convention=convention, + formatter_class=parser.formatter_class, + ) + return parser + _check_config_keys(tree, config, what="command") + _add_group( + _subparsers_of(parser), + group_name, + tree, + config=config, + convention=convention, + formatter_class=parser.formatter_class, + group_kwargs=group_kwargs, + ) + return parser + + +# -------------------------------------------------------------------------------------- +# Completion + + +def enable_completion( + parser: argparse.ArgumentParser, /, *, silent: bool = True +) -> bool: + """Offer ``parser`` to ``argcomplete``, if it is installed. + + Args: + parser: The parser to complete against. A plain ``ArgumentParser`` -- which is what + ``argcomplete.autocomplete`` is typed for, and the reason cw never subclasses. + silent: Swallow the ``ImportError`` when ``argcomplete`` is absent, which is the + point: completion is an optional extra (``pip install 'cw[completion]'``) and a + CLI must run without it. ``silent=False`` re-raises, for a script that means to + require it. + + Returns: + Whether completion was enabled. + + The import is inside the function on purpose. ``import cw`` costs stdlib only, and a + module-scope third-party import here would quietly undo that for all 66 console + scripts. There is a test that greps for it. + + >>> import argparse + >>> from cw.cli import enable_completion + >>> enable_completion(argparse.ArgumentParser()) in (True, False) + True + """ + try: + import argcomplete + except ImportError: + if not silent: + raise + return False + argcomplete.autocomplete(parser) + return True + + +# -------------------------------------------------------------------------------------- +# Running + + +def _stream(given: Optional[TextIO], name: str) -> TextIO: + """``given``, or the named ``sys`` stream looked up now rather than at import.""" + return getattr(sys, name) if given is None else given + + +def _exit_code(exc: SystemExit, err: TextIO) -> int: + """A ``SystemExit`` as the integer :func:`run` returns, mimicking the interpreter. + + ``SystemExit(None)`` is success, an ``int`` is itself, and anything else is a message: + Python prints it to stderr and exits 1, and so does cw, so that + ``raise SystemExit(cw.dispatch(...))`` ends the process the same way argh did. + """ + if exc.code is None: + return 0 + if isinstance(exc.code, int): + return exc.code + err.write(f"{exc.code}\n") + return 1 + + +def _redirect(out: Optional[TextIO], err: Optional[TextIO]): + """Point argparse's own output at ``out``/``err`` for the duration of parsing. + + ``--help``, ``usage:`` and ``error:`` come from argparse and never pass through the + egress, so this is the only way an explicit ``out=`` can capture them. It wraps + **parsing only**, never the command body: a command's own ``print`` goes where the + process's ``print`` goes, exactly as under argh, and redirecting it would be a + surprising, process-global side effect of asking for a string. + """ + stack = contextlib.ExitStack() + if out is not None: + stack.enter_context(contextlib.redirect_stdout(out)) + if err is not None: + stack.enter_context(contextlib.redirect_stderr(err)) + return stack + + +def run( + parser: argparse.ArgumentParser, + argv=None, + *, + config: Optional[Mapping] = None, + convention: Optional[Convention] = None, + egress=None, + out: Optional[TextIO] = None, + err: Optional[TextIO] = None, + standalone: bool = True, + completion: bool = True, +) -> Any: + """Parse ``argv`` with ``parser``, call the command it names, and report. + + Args: + parser: Any parser -- one from :func:`mk_parser`, or a hand-built one that has been + given a function by :func:`set_default_command`. + argv: The argument strings. ``None`` means :data:`sys.argv` ``[1:]``. Keyword or + positional: four fleet call sites spell it as a keyword. + config: Per-parameter particulars, when they were not given at build time. Only the + ``codec=`` entries can still take effect this late; the rest is token grammar + and is already in the parser. + convention: Overrides the one the parser was built with -- which is what supplies + ``egress`` when you do not pass one. + egress: Seam 2, overriding ``convention.egress`` when given. + out: Where results go. ``None`` means :data:`sys.stdout`, resolved **now**. + err: Where an expected failure goes. ``None`` means :data:`sys.stderr`, now. + standalone: ``True`` prints and returns an exit code; ``False`` returns the + function's own value and lets everything propagate. + completion: Offer the parser to ``argcomplete`` before parsing, as argh does. + + Returns: + An ``int`` exit code when ``standalone``, else whatever the command returned. + + The convention travels with the parser, so flipping to :data:`cw.MODERN` stays one act + even when the build and the run are two calls: + + >>> import cw, io + >>> def counted(): + ... return map(str, range(2)) + >>> parser = cw.mk_parser(counted, convention=cw.MODERN) + >>> out = io.StringIO() + >>> cw.run(parser, [], out=out) + 0 + >>> out.getvalue() + '0\\n1\\n' + + An expected failure is one line and an exit code; an unexpected one keeps its + traceback, because a bug deserves one: + + >>> err = io.StringIO() + >>> def risky(): + ... raise cw.CommandError('no such pipeline', code=3) + >>> cw.dispatch(risky, [], err=err) + 3 + >>> err.getvalue() + 'CommandError: no such pipeline\\n' + """ + stash = parser.get_default(RESERVED_DEST) + out = _stream(out, "stdout") + err = _stream(err, "stderr") + argv = sys.argv[1:] if argv is None else list(argv) + + if completion: + enable_completion(parser) + + with _redirect( + out if out is not sys.stdout else None, err if err is not sys.stderr else None + ): + try: + namespace = parser.parse_args(argv) + except SystemExit as exc: + if not standalone: + raise + return _exit_code(exc, err) + + # The stash the *subcommand* left behind wins over the top parser's, because it is + # the one that knows which function was chosen and under which convention it was + # built -- an `add_commands(..., convention=MODERN)` group inside an ARGH parser is + # rare, but getting it wrong would be silent. + stash = getattr(namespace, RESERVED_DEST, None) or stash + if stash is None or stash.func is None: + parser.print_usage(out) + return 0 if standalone else None + + convention = convention or stash.convention + egress = egress if egress is not None else convention.egress + args, kwargs = _call_args(stash, namespace, config=config, convention=convention) + if not standalone: + return stash.func(*args, **kwargs) + try: + result = stash.func(*args, **kwargs) + guard_no_coroutine(result) + return egress(result, out=out, err=err) + except CommandError as exc: + err.write(f"{type(exc).__name__}: {exc}\n") + return exc.code + except SystemExit as exc: + return _exit_code(exc, err) + + +def _call_args(stash: _Stash, namespace, *, config, convention) -> tuple: + """``(args, kwargs)`` for the stashed command, from the parsed namespace.""" + values = { + name: value for name, value in vars(namespace).items() if name != RESERVED_DEST + } + ingress = stash.ingress + if config is not None: + specs = specs_for_function(stash.func, convention=convention, config=config) + ingress = mk_ingress( + stash.func, + codecs={s.param_name: s.codec for s in specs if s.codec is not None}, + ) + return ingress(values) + + +def dispatch( + obj: Any, + argv=None, + *, + config: Optional[Mapping] = None, + convention: Convention = ARGH, + decode=None, + egress=None, + out: Optional[TextIO] = None, + err: Optional[TextIO] = None, + standalone: bool = True, + completion: bool = True, + **parser_kwargs, +) -> Any: + """The front door: ``dispatch == run o mk_parser``. + + Everything :func:`mk_parser` and :func:`run` take, in one call. The console-script + idiom is:: + + def main(): + raise SystemExit(cw.dispatch(COMMANDS, prog='priv')) + + and the house dispatch -- the thing that makes a project's per-call configs shrink to + nothing -- is :func:`functools.partial`, not a cw-specific binder:: + + dispatch = functools.partial(cw.dispatch, convention=cw.MODERN, prog='priv') + + >>> import cw, io + >>> def add(a: int, b: int): + ... return a + b + >>> out = io.StringIO() + >>> cw.dispatch(add, ['2', '3'], out=out) + 0 + >>> out.getvalue() + '5\\n' + + A usage error is argparse's, and keeps argparse's exit code -- which matters, because a + console script that starts exiting 0 on a bad command line breaks every CI step that + checks it: + + >>> cw.dispatch(add, ['nope'], out=io.StringIO(), err=io.StringIO()) + 2 + """ + parser = mk_parser( + obj, config=config, convention=convention, decode=decode, **parser_kwargs + ) + return run( + parser, + argv, + convention=convention, + egress=egress, + out=out, + err=err, + standalone=standalone, + completion=completion, + ) diff --git a/cw/commands.py b/cw/commands.py new file mode 100644 index 0000000..bdf9c5b --- /dev/null +++ b/cw/commands.py @@ -0,0 +1,260 @@ +"""Turning an object into the ``{name: callable}`` tree a parser is built from. + +A function, a list, a mapping, a module, an instance, or a ``'pkg.mod:name'`` reference -- +each has one meaning, and the meaning is decided by **the value, not by a string DSL**: + +====================================== =================================================== +``obj`` becomes +====================================== =================================================== +any callable **one command**, with no command word at all +``[f, g]`` -- any iterable commands named from each ``__name__`` +``{'name': f}`` -- a mapping commands named **by the key** +``{'grp': }`` a **group**: a mapping or iterable *value* is a + group, a callable value is a command +a module, or any other object its public callable attributes; ``__all__``, if + present, is the name list +``'pkg.mod:name'`` -- a string **always exactly one command**, imported lazily +====================================== =================================================== + +Grouping is exactly one level deep -- argh's limit, and the fleet's only two group users +(``t/xa`` and ``t/priv``) need exactly one. + +>>> def ls(path='.'): ... +>>> def rm(path): ... +>>> commands_from([ls, rm]) +{'ls': , 'rm': } + +**The rule that makes ``t/priv`` work: a name from a mapping key or an ``__all__`` entry +wins over the function's own ``__name__``**, and then has ``hyphenate_commands`` applied to +it like any other derived name. It is hardcoded and not configurable, because making it a +switch reintroduces exactly the ambiguity that produces the wrong name today: + +>>> import functools +>>> def packages_from_all_projects(project=None, *, config_type='setup.cfg'): ... +>>> partial = functools.partial(packages_from_all_projects, config_type='setup.cfg') +>>> list(commands_from({'packages_from_all_setup_cfgs': partial})) +['packages-from-all-setup-cfgs'] + +That is the correct name, and it was in the package's ``__all__`` the whole time. Deriving +it from the ``functools.partial`` instead gives ``packages-from-all-projects`` -- a command +that describes a different function -- which is what the live CLI registers today and what +``i2.name_of_obj`` also returns. +""" + +import importlib +import inspect +from collections.abc import Iterable, Mapping +from typing import Any, Callable, Dict, Optional, Union + +from cw.grammar import cli_name, command_name + +__all__ = ["commands_from", "import_object", "CommandTreeError"] + +#: The separator in a lazy object reference: ``'pkg.mod:name'``. +REF_SEPARATOR = ":" + +#: How deep a command tree may nest. argh's limit, and the fleet's need. +MAX_GROUP_DEPTH = 1 + +#: A tree value: a command, or (one level down) a group of them. +CommandTree = Dict[str, Union[Callable, dict]] + + +class CommandTreeError(TypeError): + """``obj`` does not describe a command tree, and guessing would ship a wrong CLI.""" + + +def import_object(ref: str) -> Any: + """``'pkg.mod:name'`` -> the object, imported now. + + The house spelling -- ``py2mcp.mk_mcp_from_refs(['pkg.tools:verb'])`` uses the same + one. The colon is required: it is what separates the module path from the attribute + path without guessing where one ends. + + >>> import_object('collections:OrderedDict') + + >>> import_object('json:JSONDecoder.decode') # doctest: +ELLIPSIS + + >>> import_object('collections.OrderedDict') + Traceback (most recent call last): + ... + ValueError: bad object reference 'collections.OrderedDict': expected 'pkg.mod:name', + with a colon between the module and the attribute. + """ + if REF_SEPARATOR not in ref: + raise ValueError( + f"bad object reference {ref!r}: expected 'pkg.mod:name', with a colon " + "between the module and the attribute." + ) + module_name, _, attribute_path = ref.partition(REF_SEPARATOR) + obj = importlib.import_module(module_name) + for attribute in attribute_path.split("."): + obj = getattr(obj, attribute) + return obj + + +def is_command(value: Any) -> bool: + """Is this tree value a command (rather than a group)? + + The discriminator, in one place: a callable value is a command, and a mapping or other + iterable value is a group. It is structural, so a ``functools.partial`` and a callable + instance -- both of which crash argh -- are simply commands. + + >>> is_command(print), is_command([print]), is_command({'a': print}) + (True, False, False) + """ + return callable(value) and not isinstance(value, (Mapping, Iterable)) + + +def _public_callables(obj: Any) -> Dict[str, Callable]: + """A module's or an instance's public callable attributes, ``__all__`` first. + + Two filters, both there to stop a CLI from sprouting commands nobody wrote. Classes + are excluded, and -- for a module with no ``__all__`` -- so is anything defined + elsewhere, or every ``from x import y`` at the top of the module would become a + command. + """ + declared = getattr(obj, "__all__", None) + names = ( + list(declared) + if declared is not None + else [name for name in dir(obj) if not name.startswith("_")] + ) + home = getattr(obj, "__name__", None) if inspect.ismodule(obj) else None + found = {} + for name in names: + value = getattr(obj, name, None) + if not callable(value) or inspect.isclass(value): + continue + if declared is None and home is not None: + if getattr(value, "__module__", home) != home: + continue + found[name] = value + return found + + +def _named(name: str, *, convention, group: bool = False) -> str: + """One naming function for derived names, mapping keys and group names alike. + + A key does not win *verbatim*: it wins over ``__name__``, and is then hyphenated by the + same rule as any other name, which is the only reading under which ``'gen-secret'``, + ``'list'`` and ``'parse_pth_paths'`` all come out right at once. + """ + hyphenate = convention.hyphenate_groups if group else convention.hyphenate_commands + return cli_name(name, hyphenate=hyphenate) + + +def commands_from(obj: Any, /, *, convention=None, _depth: int = 0) -> CommandTree: + """``obj`` -> ``{name: callable}``, with a nested mapping for each group. + + Args: + obj: Any of the six forms in this module's docstring. + convention: Supplies ``hyphenate_commands`` and ``hyphenate_groups``. Defaults to + :data:`cw.ARGH`. + + Returns: + A mapping of command-line name to callable, whose values may themselves be such a + mapping -- one level deep, no further. + + A mapping value that is itself a mapping or a list is a group, which is how ``t/xa`` + gets two different commands both called ``list``: + + >>> def list_cmd(): ... + >>> def archive_list_cmd(): ... + >>> tree = commands_from({'list': list_cmd, + ... 'archive': {'list': archive_list_cmd}}) + >>> {name: sorted(value) if isinstance(value, dict) else value.__name__ + ... for name, value in tree.items()} + {'list': 'list_cmd', 'archive': ['list']} + + Group names are **verbatim** under :data:`cw.ARGH` -- ``priv git_ops`` is a string in + priv's own README -- and hyphenated under :data:`cw.MODERN`: + + >>> import cw + >>> def g(): ... + >>> list(commands_from({'git_ops': [g]})) + ['git_ops'] + >>> list(commands_from({'git_ops': [g]}, convention=cw.MODERN)) + ['git-ops'] + + A zero-argument factory that *returns* commands is not called for you, because there is + no way to tell one from a command that happens to take no arguments -- and Python + already has parentheses: + + >>> def dispatch_funcs(): + ... return [g] + >>> list(commands_from({'git_ops': dispatch_funcs()})) + ['git_ops'] + + A list of attribute *names* -- ``priv.__all__`` is 47 strings -- is refused, with the + two spellings that do work: + + >>> commands_from(['ls', 'rm']) + Traceback (most recent call last): + ... + cw.commands.CommandTreeError: 'ls' is a string, and a list of strings is ambiguous: it + could be attribute names or object references. Pass the module itself (its __all__ is + used), or spell the mapping: {name: getattr(module, name) for name in module.__all__}. + """ + convention = convention if convention is not None else _default_convention() + + if isinstance(obj, str): + obj = import_object(obj) + if is_command(obj): + return {command_name(obj, hyphenate=convention.hyphenate_commands): obj} + if isinstance(obj, Mapping): + return _from_mapping(obj, convention=convention, _depth=_depth) + if isinstance(obj, Iterable): + return _from_iterable(obj, convention=convention) + if inspect.ismodule(obj) or hasattr(obj, "__dict__"): + return { + _named(name, convention=convention): func + for name, func in _public_callables(obj).items() + } + raise CommandTreeError( + f"cannot derive commands from {obj!r}: it is not a callable, a mapping, an " + "iterable of callables, a module, or a 'pkg.mod:name' reference." + ) + + +def _from_mapping(obj: Mapping, *, convention, _depth: int) -> CommandTree: + """Each key names its value: a command if the value is callable, a group otherwise.""" + tree: CommandTree = {} + for key, value in obj.items(): + if isinstance(value, str): + value = import_object(value) + if is_command(value): + tree[_named(key, convention=convention)] = value + elif _depth >= MAX_GROUP_DEPTH: + raise CommandTreeError( + f"group {key!r} is nested more than {MAX_GROUP_DEPTH} level deep. argh " + "supports one level of grouping and so does cw; flatten the inner group, " + "or give its commands longer names." + ) + else: + tree[_named(key, convention=convention, group=True)] = commands_from( + value, convention=convention, _depth=_depth + 1 + ) + return tree + + +def _from_iterable(obj: Iterable, *, convention) -> CommandTree: + """Each member names itself. A string member is refused rather than guessed at.""" + tree: CommandTree = {} + for value in obj: + if isinstance(value, str): + raise CommandTreeError( + f"{value!r} is a string, and a list of strings is ambiguous: it could be " + "attribute names or object references. Pass the module itself (its " + "__all__ is used), or spell the mapping: " + "{name: getattr(module, name) for name in module.__all__}." + ) + tree[command_name(value, hyphenate=convention.hyphenate_commands)] = value + return tree + + +def _default_convention(): + """:data:`cw.ARGH`, imported late so that ``convention`` may import this module.""" + from cw.convention import ARGH + + return ARGH diff --git a/cw/convention.py b/cw/convention.py index 477afee..a6d1cb2 100644 --- a/cw/convention.py +++ b/cw/convention.py @@ -16,8 +16,12 @@ >>> ARGH.naming, ARGH.short_flags, ARGH.resolve_hints ('by_name_if_has_default', True, False) +>>> ARGH.decode.__name__, ARGH.egress.__name__ +('argh_decode', 'argh_egress') >>> MODERN.naming, MODERN.resolve_hints, MODERN.hints_when_declared ('by_name_if_kwonly', True, True) +>>> MODERN.decode.__name__, MODERN.egress.__name__ +('modern_decode', 'iterable_egress') :data:`ARGH` is the default everywhere, and it reproduces argh 0.31.3 including the parts nobody likes. Every improvement is opt-in, and opting in is **one** act: @@ -33,17 +37,35 @@ >>> half_way.resolve_hints, half_way.naming (True, 'by_name_if_has_default') -Seam 2 (``egress``) becomes a field here the moment ``cw.egress`` exists; it is left out -rather than declared as a ``None`` that would have to mean two things at once. - -``decode`` is a field rather than only a ``dispatch`` keyword for one reason: if ``MODERN`` -did not carry its own, flipping to it would need a coordinated two-keyword edit at every -call site, and a seam whose replacement touches every caller is in the wrong place. +``decode`` and ``egress`` -- two of cw's three seams -- are fields here rather than only +``dispatch`` keywords, for one reason: if ``MODERN`` did not carry its own, flipping to it +would need a coordinated three-keyword edit at every call site, and a seam whose +replacement touches every caller is in the wrong place. So ``dispatch(decode=...)`` and +``dispatch(egress=...)`` default to ``None``, meaning *take the convention's*. + +**The merge ladder** -- what a convention presides over -- is four tiers, later wins:: + + 1. signature inference kind, default, name, flag spellings + 2. hint inference convention.decode(param, hint) + 3. function attribute func._cw['params'][param] (cw.compat.arg writes it) + 4. config config[...][param] (this call's particulars) + +Tiers 3 and 4 both count as "declared", so either one switches tier 2 off for the *whole* +function unless ``hints_when_declared`` says otherwise; and the merge is argh's +field-specific one, not ``dict.update`` (ADR-0003). It is implemented once, as +:func:`cw.grammar.specs_for_function`, and lives there rather than here because it is the +only place that knows what a :class:`cw.grammar.ArgSpec` is. There is no second copy. + +``formatter_class`` is deliberately **not** a field (ADR-0006): it is already an +``argparse.ArgumentParser`` keyword, and cw invents no vocabulary for anything argparse +already names. :func:`cw.mk_parser` defaults it to :class:`cw.ArghHelpFormatter` and passes +whatever you give it straight through, to the top parser and to every subparser. """ import dataclasses -from cw.base import Decode +from cw.base import Decode, Egress +from cw.egress import argh_egress, iterable_egress from cw.grammar import ( BY_NAME_IF_HAS_DEFAULT, BY_NAME_IF_KWONLY, @@ -96,6 +118,8 @@ class Convention: resolve_hints: bool = False #: Seam 1: ``(Parameter, hint) -> add_argument kwargs | callable | None``. decode: Decode = argh_decode + #: Seam 2: ``(result, *, out, err) -> exit code``. + egress: Egress = argh_egress #: cw's default in every entry point: argh 0.31.3's grammar, footguns included. @@ -104,11 +128,17 @@ class Convention: #: The same machinery with the improvements switched on. ``naming`` follows parameter #: kind rather than the presence of a default, annotations are actually resolved and #: still consulted when a parameter is overridden, groups are hyphenated like commands, -#: and ``decode`` additionally understands ``Optional[X]``, ``Enum`` and ``pathlib``. +#: ``decode`` additionally understands ``Optional[X]``, ``Enum`` and ``pathlib``, and +#: ``egress`` iterates anything iterable rather than argh's three-type whitelist. +#: +#: ``default_in_help`` stays ``True`` here (ADR-0004 rule 4): docstring-derived per-parameter +#: help is not in v1, so turning the default off would leave an undocumented flag with an +#: empty help column -- strictly worse than argh, in the value the fleet is told to adopt. MODERN = Convention( naming=BY_NAME_IF_KWONLY, hyphenate_groups=True, hints_when_declared=True, resolve_hints=True, decode=modern_decode, + egress=iterable_egress, ) diff --git a/cw/egress.py b/cw/egress.py new file mode 100644 index 0000000..d6a426d --- /dev/null +++ b/cw/egress.py @@ -0,0 +1,334 @@ +"""Result to stdout: how a function's return value becomes lines and an exit code. + +This is the seam argh has **no hook for at all**, which is why ``t/xa``'s ``cli.py`` +hand-rolls ``print(json.dumps(out, indent=2, default=str))`` *inside a command body*. In cw +it is one keyword argument:: + + cw.dispatch(commands, egress=cw.json_egress) + +Three egresses ship, and the only difference between the first two is which results count +as "several lines": + +:func:`argh_egress` + argh's ``isinstance(result, (GeneratorType, list, tuple))`` **whitelist**. A ``dict`` + prints on one line, a ``map`` object prints as ````, and a ``set`` + prints as ``{1}``. This is cw's default because D2 says the default is argh. + +:func:`iterable_egress` + The ``Iterable`` *protocol* instead, excluding ``str``, ``bytes`` and ``Mapping``. This + is :data:`cw.MODERN`'s, and it is the whole difference. + +:func:`json_egress` + One JSON document, for a command whose result is structured data rather than lines. + +**Streams resolve at call time.** ``argh`` binds ``output_file: IO = sys.stdout`` in a +signature default, so :func:`contextlib.redirect_stdout` and pytest's ``capsys`` capture +nothing from an argh CLI. cw takes ``out=None`` and looks up :data:`sys.stdout` *inside* +the call. That one line is what makes a cw CLI testable, and it is not a seam: + +>>> import io +>>> buffer = io.StringIO() +>>> argh_egress(['first', 'second'], out=buffer) +0 +>>> buffer.getvalue() +'first\\nsecond\\n' + +``None`` prints nothing, but ``0``, ``False`` and ``''`` all do -- an argh rule worth +knowing, because it is the difference between a silent command and a command that prints a +blank line: + +>>> buffer = io.StringIO() +>>> argh_egress(None, out=buffer), buffer.getvalue() +(0, '') +>>> buffer = io.StringIO() +>>> argh_egress(0, out=buffer), buffer.getvalue() +(0, '0\\n') +""" + +import inspect +import json +import sys +from collections.abc import Iterable, Iterator, Mapping +from types import GeneratorType +from typing import Any, Optional, TextIO + +__all__ = [ + "argh_egress", + "iterable_egress", + "json_egress", + "write_lines", + "confirm", + "guard_no_coroutine", +] + +#: How many spaces :func:`json_egress` indents by. +DFLT_JSON_INDENT = 2 + +#: The results :func:`argh_egress` writes one line each, rather than on one line. It is a +#: whitelist of concrete types, not the ``Iterable`` protocol, which is why a ``map`` object +#: prints its ``repr`` under ``cw.ARGH`` and its elements under ``cw.MODERN``. +ARGH_LINE_TYPES = (GeneratorType, list, tuple) + +#: What :func:`iterable_egress` treats as a single value even though it is iterable. +NOT_LINES = (str, bytes, Mapping) + +#: The answers :func:`confirm` accepts, lower-cased. +YES = ("y", "yes") +NO = ("n", "no") + + +def _stream(given: Optional[TextIO], fallback_name: str) -> TextIO: + """``given``, or the named ``sys`` stream **looked up now** rather than at import. + + Every stream in this module goes through here. That is the whole implementation of + "cw CLIs are testable and argh CLIs are not". + """ + return getattr(sys, fallback_name) if given is None else given + + +def guard_no_coroutine(result: Any) -> None: + """Raise an informative :class:`TypeError` if ``result`` is an un-awaited coroutine. + + v1 does not run event loops. argh's behaviour here is to print + ```` and warn -- having never run the body -- which is worse than + an error, so this is a deliberate, documented divergence from D2 rather than a gap in + it. It lives at the *call site* (:func:`cw.cli.run`) instead of inside one egress, so + that selecting a different egress cannot switch it off. + + >>> async def fetch(): ... + >>> guard_no_coroutine(fetch()) + Traceback (most recent call last): + ... + TypeError: fetch returned a coroutine, and cw does not run event loops. Wrap the body + in asyncio.run(...), or make the command synchronous. + """ + if not inspect.iscoroutine(result): + return + name = getattr(getattr(result, "cr_code", None), "co_name", "the command") + result.close() # or Python warns "coroutine was never awaited" at collection time + raise TypeError( + f"{name} returned a coroutine, and cw does not run event loops. " + "Wrap the body in asyncio.run(...), or make the command synchronous." + ) + + +def write_lines( + lines: Iterable, /, *, out: Optional[TextIO] = None, flush: bool = True +) -> None: + """Write ``str(line) + '\\n'`` for each item, **lazily**. + + Laziness is observable and is D2 row 18: a generator command that yields two lines and + then prompts must have those two lines on screen before the prompt. So the iterable is + never materialised, and each line is flushed as it is written (argh's ``always_flush``, + which defaults on). + + >>> import io + >>> buffer = io.StringIO() + >>> write_lines(iter([1, None, 'three']), out=buffer) + >>> buffer.getvalue() + '1\\nNone\\nthree\\n' + + Laziness, shown rather than asserted -- the first line is on the stream before the + second one has been computed: + + >>> buffer = io.StringIO() + >>> def two_lines(): + ... yield 'first' + ... print(f'(the stream already holds {buffer.getvalue()!r})') + ... yield 'second' + >>> write_lines(two_lines(), out=buffer) + (the stream already holds 'first\\n') + """ + out = _stream(out, "stdout") + flush_stream = getattr(out, "flush", None) if flush else None + for line in lines: + out.write(str(line)) + out.write("\n") + if flush_stream is not None: + flush_stream() + + +def argh_egress( + result: Any, *, out: Optional[TextIO] = None, err: Optional[TextIO] = None +) -> int: + """Seam 2's default: argh's type **whitelist**, footguns included. + + ``None`` writes nothing. A generator, ``list`` or ``tuple`` writes one line per item. + *Everything else* -- including a ``dict``, a ``set`` and a ``map`` object -- writes one + line, which is its ``str``. + + >>> import io + >>> def show(result): + ... buffer = io.StringIO() + ... argh_egress(result, out=buffer) + ... return buffer.getvalue() + >>> show(['a', 'b']) + 'a\\nb\\n' + >>> show({'a': 1, 'b': 2}) + "{'a': 1, 'b': 2}\\n" + >>> show(map(str, range(2))) # doctest: +ELLIPSIS + '\\n' + >>> show(None) + '' + + ``err`` is accepted and unused: it is part of the :data:`cw.Egress` contract, so that + an egress which *does* write to the error stream is a drop-in replacement. + """ + if result is None: + return 0 + write_lines(result if isinstance(result, ARGH_LINE_TYPES) else [result], out=out) + return 0 + + +def iterable_egress( + result: Any, *, out: Optional[TextIO] = None, err: Optional[TextIO] = None +) -> int: + """:data:`cw.MODERN`'s egress: the ``Iterable`` protocol instead of argh's whitelist. + + A ``map``, a ``set``, a ``dict_keys`` -- anything iterable that is not a ``str``, + ``bytes`` or ``Mapping`` -- writes one line per item. + + >>> import io + >>> def show(result): + ... buffer = io.StringIO() + ... iterable_egress(result, out=buffer) + ... return buffer.getvalue() + >>> show(map(str, range(2))) + '0\\n1\\n' + >>> show({'a': 1}) + "{'a': 1}\\n" + >>> show('one line') + 'one line\\n' + """ + if result is None: + return 0 + lines = ( + [result] + if isinstance(result, NOT_LINES) or not isinstance(result, Iterable) + else result + ) + write_lines(lines, out=out) + return 0 + + +def json_egress( + result: Any, + *, + out: Optional[TextIO] = None, + err: Optional[TextIO] = None, + indent: int = DFLT_JSON_INDENT, +) -> int: + """One JSON document, for a command whose result is data rather than lines. + + An iterator is materialised first (JSON has no streaming form), and anything JSON does + not know is rendered with ``str`` rather than raising -- which is what + ``t/xa/xa/cli.py``'s hand-rolled version does, and it is the behaviour a CLI wants. + + >>> import io + >>> buffer = io.StringIO() + >>> json_egress({'b': 1, 'a': [2, 3]}, out=buffer, indent=None) + 0 + >>> buffer.getvalue() + '{"b": 1, "a": [2, 3]}\\n' + + >>> buffer = io.StringIO() + >>> _ = json_egress(iter('ab'), out=buffer, indent=None) + >>> buffer.getvalue() + '["a", "b"]\\n' + """ + if result is None: + return 0 + out = _stream(out, "stdout") + if isinstance(result, Iterator): + result = list(result) + out.write(json.dumps(result, indent=indent, default=str)) + out.write("\n") + return 0 + + +# -------------------------------------------------------------------------------------- +# Interaction + + +def _prompt_for(action: str, default: Optional[bool]) -> str: + """``'Delete it? (y/N)'`` -- argh's exact spelling, trailing space and all (there is + none). + + >>> _prompt_for('Delete it', None), _prompt_for('Delete it', True) + ('Delete it? (y/n)', 'Delete it? (Y/n)') + """ + hint = {None: "y/n", True: "Y/n", False: "y/N"}[default] + return f"{action}? ({hint})" + + +def _ask(prompt: str, *, in_: Optional[TextIO], out: Optional[TextIO]) -> str: + """Show ``prompt``, read one line, strip the newline. ``EOFError`` at end of input. + + When neither stream is given this is :func:`input`, so an interactive user keeps + readline's editing and history. When either is given the streams are used directly, + which is what makes :func:`confirm` testable. + """ + if in_ is None and out is None: + return input(prompt) + out = _stream(out, "stdout") + out.write(prompt) + out.flush() + line = _stream(in_, "stdin").readline() + if line == "": + raise EOFError + return line.rstrip("\n") + + +def confirm( + action: str, + /, + *, + default: Optional[bool] = None, + skip: bool = False, + in_: Optional[TextIO] = None, + out: Optional[TextIO] = None, +) -> Optional[bool]: + """A ``y/n`` prompt, reproducing ``argh.confirm`` -- the fleet's one interaction use. + + Args: + action: What is about to happen, phrased as the subject of a question. + default: What an empty or unrecognised answer means. ``None`` means "keep asking + while the answer is empty, and return ``None`` if it is unrecognised". + skip: Return ``default`` without prompting -- for a ``--yes`` flag. + in_: Where the answer is read from. Defaults to :data:`sys.stdin`, resolved now. + out: Where the prompt is written. Defaults to :data:`sys.stdout`, resolved now. + + >>> import io + >>> out = io.StringIO() + >>> confirm('Delete everything', in_=io.StringIO('y\\n'), out=out) + True + >>> out.getvalue() + 'Delete everything? (y/n)' + + A default is shown by which letter is capitalised, and is what an empty answer means: + + >>> confirm('Proceed', default=True, in_=io.StringIO('\\n'), out=io.StringIO()) + True + >>> confirm('Proceed', default=False, in_=io.StringIO('\\n'), out=io.StringIO()) + False + + ``skip=True`` is how a ``--yes`` flag is spelt; nothing is read and nothing is written: + + >>> confirm('Proceed', default=True, skip=True) is True + True + + Unlike the streams, the *questions* are not a seam: this is a y/n prompt, and anything + richer is the caller's own code. + """ + if skip: + return default + prompt = _prompt_for(action, default) + while True: + answer = _ask(prompt, in_=in_, out=out).lower() + if answer in YES: + return True + if answer in NO: + return False + if answer == "" and default is None: + continue # argh keeps asking rather than returning an ambiguous None + return default diff --git a/cw/ingress.py b/cw/ingress.py new file mode 100644 index 0000000..75ecdf5 --- /dev/null +++ b/cw/ingress.py @@ -0,0 +1,182 @@ +"""Namespace to call: the lines that honour ``/``, ``*args``, ``*`` and ``**kwargs``. + +This is the one job ``i2.Sig.mk_args_and_kwargs`` was going to buy, and it is a page of +:mod:`inspect`. argh does the same job in its own dispatcher without i2, and so does cw: +split ``POSITIONAL_ONLY`` and ``POSITIONAL_OR_KEYWORD`` into positional arguments, put +``KEYWORD_ONLY`` into a keyword dict, extend the positionals with ``VAR_POSITIONAL``, and +collect whatever is left over for ``VAR_KEYWORD``. + +:func:`mk_ingress` returns a callable whose contract is ``{name: value} -> (args, kwargs)`` +-- deliberately the *same* contract as ``i2.wrapper.Ingress``, so that an i2 ``Ingress`` +remains a drop-in substitute for anyone who wants one. The contract is honoured; the +import is not paid. (It is reachable as ``cw.ingress.mk_ingress`` and is deliberately not +re-exported from the ``cw`` facade: nobody has asked for it as a public name.) + +>>> def move(source, /, destination='.', *extra, force=False, **options): +... ... +>>> ingress = mk_ingress(move) +>>> ingress({'source': 'a', 'destination': 'b', 'extra': ['c', 'd'], +... 'force': True, 'dry_run': 'yes'}) +(('a', 'b', 'c', 'd'), {'force': True, 'dry_run': 'yes'}) + +Note what that shows and what it hides. ``source`` is positional-only and ``destination`` +is not, yet both are passed positionally -- because a parameter's *kind* decides how it is +called, while :mod:`cw.grammar` decides, separately, whether it is spelt ``dest`` or +``--destination`` on the command line. And ``dry_run`` reaches ``**options`` only because +something put it in the mapping; under :data:`cw.ARGH` the parser never adds an argument +for ``**options`` at all, so in a real dispatch that key is simply never there. +""" + +import inspect +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple + +__all__ = ["mk_ingress"] + +#: Parameter kinds that are passed by position, whatever their command-line spelling. +POSITIONAL_KINDS = ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, +) + + +class IngressError(KeyError): + """A parsed namespace cannot be turned into a call to this function. + + In practice this means a parameter with no default was hidden from the command line + (``config={'x': cw.HIDE}``), so nothing supplies it and the function's own signature + cannot either. + """ + + def __str__(self) -> str: # KeyError's repr()s its argument; this is a message + return self.args[0] + + +class _Kinds: + """``func``'s parameters, bucketed by kind once, at parser-build time. + + Everything :func:`mk_ingress` needs to know about a signature, computed before the + first command line is ever seen -- ``inspect.signature`` is the expensive part and it + happens once per command, not once per call. + """ + + __slots__ = ( + "name", + "positional", + "keyword_only", + "var_positional", + "var_keyword", + "defaults", + ) + + def __init__(self, func: Any): + parameters = list(inspect.signature(func).parameters.values()) + self.name = getattr(func, "__name__", repr(func)) + self.positional: List[str] = [ + p.name for p in parameters if p.kind in POSITIONAL_KINDS + ] + self.keyword_only: List[str] = [ + p.name for p in parameters if p.kind == p.KEYWORD_ONLY + ] + self.var_positional: Optional[str] = next( + (p.name for p in parameters if p.kind == p.VAR_POSITIONAL), None + ) + self.var_keyword: bool = any(p.kind == p.VAR_KEYWORD for p in parameters) + self.defaults: Dict[str, Any] = { + p.name: p.default for p in parameters if p.default is not p.empty + } + + @property + def named(self) -> frozenset: + """Every parameter name the signature spells out, so leftovers can be spotted.""" + return frozenset( + self.positional + + self.keyword_only + + ([self.var_positional] if self.var_positional else []) + ) + + +def mk_ingress( + func: Any, /, *, codecs: Optional[Mapping[str, Callable[[Any], Any]]] = None +) -> Callable[[Mapping[str, Any]], Tuple[tuple, dict]]: + """Build ``{param_name: value} -> (args, kwargs)`` for one function. + + Args: + func: The command. Inspected once, here, not on every call. + codecs: Optional per-parameter post-parse decoders, keyed by parameter name. + :mod:`cw.cli` fills this from each :class:`cw.grammar.ArgSpec`'s ``codec``, so + that the promotion of a bare callable to a :class:`cw.Codec` happens in exactly + one place. This is the "ingress site" of the two conversion sites -- the other + is argparse's ``type=``, and which one a conversion runs at is decided per + parameter by which key you wrote. + + Returns: + The ingress: a callable taking one mapping and returning ``(args, kwargs)``. + + Four rules, each of which is observable, and three of which are argh's: + + A ``*args`` parameter re-expands by extension rather than being passed as a tuple, + which is why ``def estimate(*agents)`` needs no configuration at all: + + >>> mk_ingress(lambda *agents: None)({'agents': ['a', 'b']}) + (('a', 'b'), {}) + + A parameter absent from the mapping falls back to the function's own default. That is + what makes ``cw.HIDE`` work: the argument is gone from the command line, and the + function still receives the value it was partial-ed with. + + >>> def packages(project=None, *, config_type='setup.cfg'): ... + >>> mk_ingress(packages)({'project': 'p'}) + (('p',), {'config_type': 'setup.cfg'}) + + A parameter that is neither supplied nor defaulted is an error naming the likely + cause, rather than a :class:`TypeError` from deep inside the call: + + >>> mk_ingress(lambda pool: None)({}) + Traceback (most recent call last): + ... + cw.ingress.IngressError: : no value for parameter 'pool', and it has no + default. Was it hidden with cw.HIDE? + + Leftover keys go to ``**kwargs`` when the function takes it, and are dropped when it + does not -- and under :data:`cw.ARGH` nothing ever creates such a key, because argh + contributes no command-line argument for ``**kwargs`` at all: + + >>> mk_ingress(lambda a, **rest: None)({'a': 1, 'extra': 2}) + ((1,), {'extra': 2}) + >>> mk_ingress(lambda a: None)({'a': 1, 'extra': 2}) + ((1,), {}) + """ + kinds = _Kinds(func) + codecs = dict(codecs or {}) + named = kinds.named + + def ingress(values: Mapping[str, Any]) -> Tuple[tuple, dict]: + if codecs: + values = { + name: codecs[name](value) if name in codecs else value + for name, value in values.items() + } + + def value_of(name: str) -> Any: + if name in values: + return values[name] + if name in kinds.defaults: + return kinds.defaults[name] + raise IngressError( + f"{kinds.name}: no value for parameter {name!r}, and it has no default. " + "Was it hidden with cw.HIDE?" + ) + + args = [value_of(name) for name in kinds.positional] + if kinds.var_positional: + args.extend(values.get(kinds.var_positional) or ()) + kwargs = {name: value_of(name) for name in kinds.keyword_only} + if kinds.var_keyword: + kwargs.update( + (name, value) + for name, value in values.items() + if name not in named and not name.startswith("_") + ) + return tuple(args), kwargs + + return ingress diff --git a/cw/util.py b/cw/util.py deleted file mode 100644 index d78d13a..0000000 --- a/cw/util.py +++ /dev/null @@ -1 +0,0 @@ -"""Utility functions for the CW module.""" diff --git a/tests/argh_parity/test_cli_parity.py b/tests/argh_parity/test_cli_parity.py new file mode 100644 index 0000000..3870bbe --- /dev/null +++ b/tests/argh_parity/test_cli_parity.py @@ -0,0 +1,411 @@ +"""Run the same command set through argh and through cw, and diff what a user sees. + +`test_parity.py` diffs the parser argh and cw *build* for one function. This module diffs +what happens when you actually run one: exit code, stdout and stderr, byte for byte, over +subcommands, groups, every egress shape and every error shape. + +The parity surface is deliberately observable behaviour and nothing else -- never the +`Namespace`, never the action table -- because cw registers a hyphenated positional as +`add_argument('project_dir', metavar='project-dir')` where argh registers +`add_argument('project-dir')`. That difference is invisible to a user and permitted; a +different flag or a different line of output is not. + +argh is a test-only dependency. Nothing under `cw/` imports it. +""" + +import contextlib +import io +import os + +import argh +import pytest + +import cw + +os.environ.setdefault("COLUMNS", "100") + + +# --------------------------------------------------------------------------- commands + + +def alpha(x=1): + """Alpha does a thing.""" + return x + + +def beta_go(y="z"): + """Beta.""" + return y + + +def gamma(): + return "g" + + +def echo(word, *, loud=False): + """Echo a word.""" + return word.upper() if loud else word + + +def r_none(): + return None + + +def r_zero(): + return 0 + + +def r_false(): + return False + + +def r_empty(): + return "" + + +def r_list(): + return [1, 2] + + +def r_tuple(): + return (1, 2) + + +def r_dict(): + return {"a": 1, "b": 2} + + +def r_gen(): + yield "a" + yield "b" + + +def r_set(): + return {1} + + +def r_str(): + return "hello" + + +def r_int(): + return 42 + + +def r_nested(): + return [[1, 2], {"k": "v"}] + + +def _cmderr(error_class): + def command(): + raise error_class("boom") + + return command + + +def _cmderr7(error_class): + def command(): + raise error_class("boom", code=7) + + return command + + +def _sysexit_str(error_class): + def command(): + raise SystemExit("bye") + + return command + + +def _sysexit_int(error_class): + def command(): + raise SystemExit(3) + + return command + + +def _gen_then_err(error_class): + def command(): + yield "line-1" + yield "line-2" + raise error_class("after streaming") + + return command + + +#: argh and cw must agree on these one-command results. `map` is excluded on purpose: it +#: is the one shape where `cw.MODERN` deliberately differs, and it has its own test. +RESULT_FUNCS = [ + r_none, + r_zero, + r_false, + r_empty, + r_list, + r_tuple, + r_dict, + r_gen, + r_set, + r_str, + r_int, + r_nested, +] + +#: Each builds the same command twice, once against each library's `CommandError`, since +#: neither library catches the other's. +ERROR_BUILDERS = [_cmderr, _cmderr7, _sysexit_str, _sysexit_int, _gen_then_err] + + +# ---------------------------------------------------------------------------- running + + +def _capture(call): + """`call(out, err) -> code`, with argparse's own output captured into the buffers.""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + code = call(out, err) + except SystemExit as exc: + code = exc.code + return code, out.getvalue(), err.getvalue() + + +def run_argh(build, argv): + """Build an argh parser with `build(parser)` and dispatch `argv` through it.""" + + def call(out, err): + parser = argh.ArghParser(prog="x") + build(parser) + argh.dispatch(parser, list(argv), output_file=out, errors_file=err) + return 0 + + return _capture(call) + + +def run_cw(obj, argv, **kwargs): + """Dispatch `argv` against `obj` through cw, capturing everything.""" + return _capture( + lambda out, err: cw.dispatch( + obj, list(argv), out=out, err=err, prog="x", **kwargs + ) + ) + + +def as_process_would(outcome): + """Normalise the one in-process difference into what the shell would actually see. + + argh lets a `SystemExit` out of `dispatch`, so a `SystemExit('bye')` reaches the + interpreter, which prints `bye` to stderr and exits 1. cw's `dispatch` returns an + integer, so it does that printing itself and returns 1. Same two observables, produced + one stack frame apart -- and this function is the only place the difference is + forgiven. + """ + code, out, err = outcome + if isinstance(code, str): + return 1, out, err + f"{code}\n" + return outcome + + +def assert_same(left, right, label=""): + """Exit code, stdout and stderr identical, with a readable message when not.""" + for name, a, b in zip(("exit", "stdout", "stderr"), left, right): + assert a == b, f"{label}{name}: argh={a!r} cw={b!r}" + + +# ------------------------------------------------------------------------------ tests + + +@pytest.mark.parametrize("func", RESULT_FUNCS, ids=lambda f: f.__name__) +def test_egress_matches_argh(func): + """Every result shape prints the way argh prints it (spec section 9 rows 16-18).""" + assert_same( + run_argh(lambda p: p.set_default_command(func), []), + run_cw(func, []), + label=f"{func.__name__} ", + ) + + +@pytest.mark.parametrize("builder", ERROR_BUILDERS, ids=lambda b: b.__name__) +def test_errors_match_argh(builder): + """CommandError, SystemExit and lazy streaming behave identically (row 19).""" + assert_same( + as_process_would( + run_argh(lambda p: p.set_default_command(builder(argh.CommandError)), []) + ), + as_process_would(run_cw(builder(cw.CommandError), [])), + label=f"{builder.__name__} ", + ) + + +def test_unexpected_exception_keeps_its_traceback(): + """An unexpected failure is a bug, and a bug deserves a traceback -- in both.""" + + def boom(): + raise ValueError("unexpected") + + with pytest.raises(ValueError): + run_argh(lambda p: p.set_default_command(boom), []) + with pytest.raises(ValueError): + run_cw(boom, []) + + +FLAT_ARGVS = [ + [], + ["--help"], + ["alpha"], + ["alpha", "--help"], + ["alpha", "-x", "5"], + ["beta-go"], + ["gamma"], + ["gamma", "--help"], + ["nope"], + ["alpha", "--nope"], +] + + +@pytest.mark.parametrize("argv", FLAT_ARGVS, ids=lambda a: " ".join(a) or "(none)") +def test_flat_subcommands_match_argh(argv): + """A list of functions produces the same subcommands, help and errors.""" + functions = [alpha, beta_go, gamma] + assert_same( + run_argh(lambda p: p.add_commands(functions), argv), + run_cw(functions, argv), + label=f"{argv} ", + ) + + +GROUP_KWARGS = [ + None, + {"help": "HELPTEXT", "title": "TITLETEXT"}, + {"help": "Postmortem archive (list, log, forensics)."}, + {"title": "Only a title."}, +] + +GROUP_ARGVS = [[], ["--help"], ["grp", "--help"], ["grp", "alpha", "-x", "2"]] + + +@pytest.mark.parametrize( + "group_kwargs", GROUP_KWARGS, ids=lambda k: str(sorted(k or {})) +) +@pytest.mark.parametrize("argv", GROUP_ARGVS, ids=lambda a: " ".join(a) or "(none)") +def test_group_matches_argh(group_kwargs, argv): + """`group_kwargs` reaches both `add_parser(help=title)` and `add_subparsers(**it)`. + + The trap this pins: the parent's listing row reads `title`, not `help`, so a group + whose only `group_kwargs` is `help` shows a blank row there -- and still shows that + help *inside* the group. Translating `help` onto the parent row would add a line argh + never shows; dropping it would delete one argh does show. + """ + + def build_argh(parser): + parser.add_commands( + [alpha, beta_go], group_name="grp", group_kwargs=group_kwargs + ) + + def build_cw(out, err): + parser = cw.mk_parser({}, prog="x") + cw.add_commands( + parser, [alpha, beta_go], group_name="grp", group_kwargs=group_kwargs + ) + return cw.run(parser, list(argv), out=out, err=err) + + assert_same(run_argh(build_argh, argv), _capture(build_cw), label=f"{argv} ") + + +def test_underscore_group_name_is_verbatim(): + """`priv git_ops` stays `git_ops` under ARGH -- the string is in priv's own README.""" + argv = ["--help"] + assert_same( + run_argh(lambda p: p.add_commands([alpha], group_name="git_ops"), argv), + _capture( + lambda out, err: cw.dispatch( + {"git_ops": [alpha]}, list(argv), out=out, err=err, prog="x" + ) + ), + ) + + +@pytest.mark.parametrize( + "argv", + [ + [], + ["--help"], + ["list"], + ["gen-secret"], + ["archive", "--help"], + ["archive", "list"], + ], +) +def test_mapping_named_commands_match_name_mutation(argv): + """cw's `{name: func}` form equals what `t/xa` does today by mutating `__name__`. + + argh rejects a Mapping outright, so the thing to compare against is the workaround it + forces: thirteen `__name__` assignments in `xa/cli.py`, two of which write the same + string onto different functions. The Mapping form is why those two can coexist. + """ + + def list_cmd(): + """List sessions.""" + return "top-list" + + def archive_list_cmd(): + """List archived sessions.""" + return "archive-list" + + def gen_secret_cmd(): + """Make a secret.""" + return "secret" + + def build_argh(parser): + list_cmd.__name__ = "list" + gen_secret_cmd.__name__ = "gen-secret" + archive_list_cmd.__name__ = "list" + parser.add_commands([list_cmd, gen_secret_cmd]) + parser.add_commands([archive_list_cmd], group_name="archive") + + commands = { + "list": list_cmd, + "gen-secret": gen_secret_cmd, + "archive": {"list": archive_list_cmd}, + } + assert_same(run_argh(build_argh, argv), run_cw(commands, argv), label=f"{argv} ") + + +@pytest.mark.parametrize( + "argv", [["echo", "hi"], ["echo", "hi", "--loud"], ["echo"], ["echo", "--help"]] +) +def test_ingress_delivers_what_argh_delivers(argv): + """The arguments `f` actually receives -- the parity surface, not the Namespace.""" + assert_same( + run_argh(lambda p: p.add_commands([echo]), argv), + run_cw([echo], argv), + label=f"{argv} ", + ) + + +def test_map_is_where_modern_deliberately_differs(): + """`argh_egress` is a type whitelist; `iterable_egress` is the Iterable protocol.""" + + def counted(): + return map(str, range(2)) + + argh_code, argh_out, _ = run_argh(lambda p: p.set_default_command(counted), []) + cw_code, cw_out, _ = run_cw(counted, []) + assert (cw_code, cw_out.startswith("`.""" + + def test_names_asyncio_run(self): + async def fetch(): + return 1 + + with pytest.raises(TypeError, match="asyncio.run"): + guard_no_coroutine(fetch()) + + @pytest.mark.parametrize( + "egress", [None, cw.argh_egress, cw.iterable_egress, cw.json_egress] + ) + def test_fires_whichever_egress_is_selected(self, egress): + """The check is at the call site, so no egress choice can switch it off.""" + + async def fetch(): + return 1 + + with pytest.raises(TypeError, match="coroutine"): + cw.dispatch(fetch, [], egress=egress, out=io.StringIO()) + + def test_a_plain_result_passes_through(self): + assert guard_no_coroutine([1, 2]) is None + + +class TestConfirm: + @pytest.mark.parametrize( + "default, typed, expected", + [ + (None, "y\n", True), + (None, "Y\n", True), + (None, "yes\n", True), + (None, "n\n", False), + (None, "no\n", False), + (None, "garbage\n", None), + (True, "\n", True), + (True, "garbage\n", True), + (False, "\n", False), + (False, "y\n", True), + ], + ) + def test_answers(self, default, typed, expected): + assert ( + cw.confirm("Go", default=default, in_=io.StringIO(typed), out=io.StringIO()) + is expected + ) + + def test_an_empty_answer_with_no_default_asks_again(self): + out = io.StringIO() + assert cw.confirm("Go", in_=io.StringIO("\n\ny\n"), out=out) is True + assert out.getvalue() == "Go? (y/n)" * 3 + + @pytest.mark.parametrize( + "default, prompt", + [(None, "Go? (y/n)"), (True, "Go? (Y/n)"), (False, "Go? (y/N)")], + ) + def test_prompt_spelling(self, default, prompt): + """argh's exact spelling, trailing space and all -- of which there is none.""" + out = io.StringIO() + cw.confirm("Go", default=default, in_=io.StringIO("y\n"), out=out) + assert out.getvalue() == prompt + + def test_skip_returns_the_default_and_asks_nothing(self): + out = io.StringIO() + assert cw.confirm("Go", default=True, skip=True, out=out) is True + assert out.getvalue() == "" + + def test_the_interactive_path_uses_input(self, monkeypatch): + """With neither stream given, `input()` is used, so readline editing survives.""" + asked = [] + monkeypatch.setattr( + "builtins.input", lambda prompt: asked.append(prompt) or "y" + ) + assert cw.confirm("Go") is True + assert asked == ["Go? (y/n)"] + + def test_end_of_input_raises(self): + with pytest.raises(EOFError): + cw.confirm("Go", in_=io.StringIO(""), out=io.StringIO()) diff --git a/tests/test_fleet_shapes.py b/tests/test_fleet_shapes.py new file mode 100644 index 0000000..95e9c73 --- /dev/null +++ b/tests/test_fleet_shapes.py @@ -0,0 +1,204 @@ +"""Two fleet shapes that only exist once there is a parser: `t/priv` and `t/theremin`. + +The grammar differential already pins how a *signature* becomes arguments. These two are +about what the assembled CLI does, and they are the two the migration is judged on: + +* **`t/priv`** -- 47 top-level commands taken from a list of strings, three groups built by + zero-argument factories, and one `functools.partial` whose command name argh gets wrong + and whose pre-bound keyword argh re-exposes. argh cannot express this shape at all + (it crashes on the partial and rejects a mapping), so the assertions are cw's own, and + each one names the defect it removes. +* **`t/theremin`** -- ten declared arguments, five of them `nargs='?' const='list'`, and + explicit short flags on parameters whose inferred short flag was suppressed by a + collision. argh *can* express this, so it is a differential. +""" + +import functools +import io + +import argh +import pytest + +import cw + +from tests.argh_parity.test_cli_parity import _capture, run_argh + + +# ------------------------------------------------------------------ t/priv, shape only + + +def packages_from_all_projects(proj_folder=None, *, config_type="setup.cfg"): + """List packages found in every project.""" + return f"{proj_folder}:{config_type}" + + +def parse_pth_paths(pth_file="~/.pth"): + """Parse a .pth file.""" + return pth_file + + +def git_status(): + """Show status.""" + return "clean" + + +def pkg_list(): + """List packages.""" + return "listed" + + +#: What `priv/commands.py` looks like after the migration: the `__all__` key is the +#: command name, and the three group sources are called, because Python has parentheses. +PRIV_COMMANDS = { + "packages_from_all_setup_cfgs": functools.partial( + packages_from_all_projects, config_type="setup.cfg" + ), + "parse_pth_paths": parse_pth_paths, + "git_ops": {"status": git_status}, + "pkg": [pkg_list], +} + +#: The partial pre-binds `config_type`; do not re-expose it as a flag. +PRIV_CONFIG = {"packages-from-all-setup-cfgs": {"config_type": cw.HIDE}} + + +def priv(argv): + with pytest.warns(UserWarning) if not PRIV_CONFIG else _no_warning(): + return _capture( + lambda out, err: cw.dispatch( + PRIV_COMMANDS, + list(argv), + config=PRIV_CONFIG, + out=out, + err=err, + prog="priv", + ) + ) + + +def _no_warning(): + import warnings + + return warnings.catch_warnings() + + +class TestPrivShape: + def test_the_partial_gets_the_name_from_its_key(self): + """Today's CLI registers `packages-from-all-projects` -- a different function.""" + code, out, _ = priv(["--help"]) + assert code == 0 + assert "packages-from-all-setup-cfgs" in out + assert "packages-from-all-projects" not in out + + def test_the_pre_bound_keyword_is_gone_from_the_cli(self): + _, out, _ = priv(["packages-from-all-setup-cfgs", "--help"]) + assert "--config-type" not in out + + def test_and_the_function_still_receives_it(self): + """`cw.HIDE` removes the argument, not the value.""" + assert priv(["packages-from-all-setup-cfgs", "-p", "P"])[1] == "P:setup.cfg\n" + + def test_group_names_are_verbatim_under_argh(self): + """`priv git_ops` is a string in priv's README and in two of its own messages.""" + _, out, _ = priv(["--help"]) + assert "git_ops" in out and "git-ops" not in out + + def test_a_group_command_runs(self): + assert priv(["git_ops", "status"]) == (0, "clean\n", "") + + def test_a_factory_result_becomes_a_group_too(self): + assert priv(["pkg", "pkg-list"]) == (0, "listed\n", "") + + def test_a_top_level_command_is_hyphenated_from_its_key(self): + assert priv(["parse-pth-paths"])[1] == "~/.pth\n" + + def test_without_the_hide_line_the_leak_is_warned_about(self): + with pytest.warns(UserWarning, match="config_type"): + cw.mk_parser(PRIV_COMMANDS, prog="priv") + + +# --------------------------------------------------------- t/theremin, as a differential + + +def _declare(*flags, **kwargs): + """`@argh.arg` and cw's `func._cw` from one call, so the two cannot drift.""" + + def decorate(func): + func = argh.arg(*flags, **kwargs)(func) + name = (flags[-1] if len(flags) > 1 else flags[0]).lstrip("-").replace("-", "_") + params = dict(getattr(func, "_cw", {}).get("params", {})) + func._cw = {"params": {name: dict(kwargs, flags=list(flags)), **params}} + return func + + return decorate + + +@_declare("-p", "--pipeline", nargs="?", const="list", default="theremin") +@_declare("-s", "--synth", nargs="?", const="list", default="sine") +@_declare("-k", "--scale", nargs="?", const="list", default=None) +@_declare("-n", "--seconds", default=10) +def theremin_cli(pipeline="theremin", synth="sine", scale=None, seconds=10): + """Run the theremin.""" + return f"{pipeline}|{synth}|{scale}|{seconds}" + + +THEREMIN_ARGVS = [ + [], + ["--help"], + ["-p"], + ["-p", "bass"], + ["--pipeline"], + ["--synth"], + ["-s", "saw"], + ["--scale"], + ["-n", "3"], +] + + +@pytest.mark.parametrize("argv", THEREMIN_ARGVS, ids=lambda a: " ".join(a) or "(none)") +def test_theremin_matches_argh(argv): + """Including the two things that look like special cases and are neither. + + `--synth [SYNTH], -s [SYNTH]` renders long-first because the declared flags are + *appended* to the inferred ones, and `--scale` gets no short flag at all because + `synth`, `scale` and `seconds` all start with `s`. No line of cw knows about either. + """ + left = run_argh(lambda p: p.set_default_command(theremin_cli), argv) + right = _capture( + lambda out, err: cw.dispatch( + theremin_cli, list(argv), out=out, err=err, prog="x" + ) + ) + for name, a, b in zip(("exit", "stdout", "stderr"), left, right): + assert a == b, f"{argv} {name}: argh={a!r} cw={b!r}" + + +def test_the_declared_flags_are_appended_not_replaced(): + help_text = cw.mk_parser(theremin_cli, prog="theremin").format_help() + assert "--synth [SYNTH], -s [SYNTH]" in help_text + assert "-k, --scale" not in help_text and "--scale [SCALE]" in help_text + + +def test_the_sentinel_survives_a_codec(): + """The one case a `codec=` exists for, and the reason it is not at argparse's `type=`. + + `type=` would be applied to the string *default* and to the `const` as well, so a + resolver at that site would be handed `'theremin'` and `'list'` and asked to import + them. The ingress site sees the value after parsing, and `passthrough` keeps the + sentinel out of the resolver's hands. + """ + resolved = {"bass": "BASS-PIPELINE", "theremin": "THEREMIN-PIPELINE"} + config = { + "pipeline": { + "codec": cw.Codec(decode=resolved.__getitem__, passthrough={"list"}) + } + } + outcomes = { + tuple(argv): cw.dispatch(theremin_cli, argv, config=config, standalone=False) + for argv in ([], ["-p"], ["-p", "bass"]) + } + assert outcomes == { + (): "THEREMIN-PIPELINE|sine|None|10", + ("-p",): "list|sine|None|10", + ("-p", "bass"): "BASS-PIPELINE|sine|None|10", + } diff --git a/tests/test_ingress.py b/tests/test_ingress.py new file mode 100644 index 0000000..db35f00 --- /dev/null +++ b/tests/test_ingress.py @@ -0,0 +1,193 @@ +"""`cw.ingress`: every parameter kind, and the two rules that are not about kinds. + +The claim under test is that a page of `inspect` does the job `i2.Sig.mk_args_and_kwargs` +was going to do. The way to falsify it is to call the ingress and then call the function +with what it produced, which is what `calls_like` does -- so a wrong split shows up as the +wrong arguments arriving, not as a mismatched tuple. +""" + +import inspect + +import pytest + +import cw +from cw.ingress import IngressError, mk_ingress + + +def split(func, values): + """The `(args, kwargs)` the ingress makes of `values` -- the thing under test.""" + return mk_ingress(func)(values) + + +def calls_like(func, values): + """What `func` actually receives: the ingress output, applied. + + Asserting on the call rather than only on the split is what catches a split that is + tuple-shaped but wrong -- `*args` re-packed instead of extended, say. + """ + args, kwargs = split(func, values) + return func(*args, **kwargs) + + +def record(*args, **kwargs): + """A stand-in command that reports exactly what it was handed.""" + return args, kwargs + + +class TestParameterKinds: + def test_positional_only(self): + def f(a, b, /): ... + + assert split(f, {"a": 1, "b": 2}) == ((1, 2), {}) + + def test_positional_or_keyword_is_passed_positionally(self): + """Kind decides how it is called; `cw.grammar` decides, separately, how it is + spelt. A defaulted parameter is an `--option` under ARGH and still arrives by + position.""" + + def f(a, b=2): ... + + assert split(f, {"a": 1, "b": 9}) == ((1, 9), {}) + + def test_keyword_only(self): + def f(*, a, b=2): ... + + assert split(f, {"a": 1, "b": 9}) == ((), {"a": 1, "b": 9}) + + def test_var_positional_extends_rather_than_nesting(self): + def f(*agents): + return record(*agents) + + assert calls_like(f, {"agents": ["a", "b"]}) == (("a", "b"), {}) + + def test_var_positional_absent_is_empty(self): + def f(*agents): + return record(*agents) + + assert calls_like(f, {}) == ((), {}) + assert calls_like(f, {"agents": None}) == ((), {}) + + def test_var_positional_is_not_re_packed(self): + """The split extends the positionals; it does not pass one tuple argument.""" + assert split(lambda *agents: None, {"agents": ["a", "b"]}) == (("a", "b"), {}) + + def test_var_keyword_collects_leftovers(self): + def f(a, **rest): + return record(a, **rest) + + assert calls_like(f, {"a": 1, "x": 2}) == ((1,), {"x": 2}) + + def test_leftovers_are_dropped_without_var_keyword(self): + """Row 11: argh contributes no CLI argument for `**kwargs`, and drops the rest.""" + + def f(a): ... + + assert split(f, {"a": 1, "x": 2}) == ((1,), {}) + + def test_private_keys_never_reach_var_keyword(self): + """cw's own namespace key must not land in someone's `**kwargs`.""" + + def f(**rest): ... + + assert split(f, {"_cw": object(), "x": 1}) == ((), {"x": 1}) + + def test_all_kinds_at_once(self): + def f(po, /, pk=2, *args, ko=3, **rest): + return record(po, pk, *args, ko=ko, **rest) + + assert calls_like( + f, {"po": "P", "pk": "K", "args": ["A", "B"], "ko": 7, "extra": "E"} + ) == (("P", "K", "A", "B"), {"ko": 7, "extra": "E"}) + + def test_zero_parameters(self): + assert split(lambda: None, {}) == ((), {}) + + +class TestDefaultsAndHiding: + def test_a_missing_value_falls_back_to_the_signature_default(self): + """What makes `cw.HIDE` work: the flag is gone and the function still gets it.""" + + def packages(project=None, *, config_type="setup.cfg"): + return record(project, config_type=config_type) + + assert calls_like(packages, {"project": "p"}) == ( + ("p",), + {"config_type": "setup.cfg"}, + ) + + def test_hide_removes_the_flag_and_keeps_the_value(self): + """End to end, on the shape `t/priv`'s one `functools.partial` actually has.""" + seen = {} + + def packages_from_all_projects(project=None, *, config_type="setup.cfg"): + seen.update(project=project, config_type=config_type) + + parser = cw.mk_parser( + packages_from_all_projects, config={"config_type": cw.HIDE}, prog="priv" + ) + assert "--config-type" not in parser.format_help() + cw.run(parser, ["--project", "p"], standalone=False) + assert seen == {"project": "p", "config_type": "setup.cfg"} + + def test_a_missing_value_with_no_default_is_an_informative_error(self): + def f(pool): + return pool + + with pytest.raises(IngressError, match="cw.HIDE"): + mk_ingress(f)({}) + + +class TestCodecs: + """The ingress site: a post-parse, per-parameter, opt-in decoder.""" + + def test_applies_to_the_named_parameter_only(self): + def f(a, b): + return record(a, b) + + args, _ = mk_ingress(f, codecs={"a": str.upper})({"a": "x", "b": "y"}) + assert args == ("X", "y") + + def test_a_codec_from_config_reaches_the_ingress(self): + seen = {} + + def load(pipeline="theremin"): + seen["pipeline"] = pipeline + + pipelines = {"bass": "BASS-OBJECT"} + cw.dispatch( + load, + ["--pipeline", "bass"], + config={"pipeline": {"codec": pipelines.__getitem__}}, + standalone=False, + ) + assert seen == {"pipeline": "BASS-OBJECT"} + + def test_passthrough_lets_a_sentinel_survive(self): + """Exactly `t/theremin`'s shape: `nargs='?' const='list'` plus a resolver.""" + seen = {} + + def play(pipeline="theremin"): + seen["pipeline"] = pipeline + + codec = cw.Codec(decode=str.upper, passthrough={"list"}) + config = {"pipeline": {"nargs": "?", "const": "list", "codec": codec}} + for argv, expected in ( + ([], "THEREMIN"), + (["--pipeline"], "list"), + (["--pipeline", "bass"], "BASS"), + ): + cw.dispatch(play, argv, config=config, standalone=False) + assert seen["pipeline"] == expected, argv + + +def test_the_call_contract_is_i2s(): + """`{name: value} -> (args, kwargs)`, so an `i2.wrapper.Ingress` is a drop-in. + + Asserted structurally rather than by importing i2, which is the whole point: the + contract is honoured and the 32ms import is not paid. + """ + ingress = mk_ingress(lambda a, *, b=1: None) + assert len(inspect.signature(ingress).parameters) == 1 + result = ingress({"a": 1}) + assert isinstance(result, tuple) and len(result) == 2 + assert isinstance(result[0], tuple) and isinstance(result[1], dict) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..063a57b --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,95 @@ +"""`python -m cw`: cw's own CLI, which is built with cw. + +Dogfood. If cw could not build its own CLI comfortably -- a mapping of three plain +functions, one of which is a generator -- that would be worth knowing before 66 fleet +console scripts find out. +""" + +import io +import subprocess +import sys + +import pytest + +import cw +from cw.__main__ import COMMANDS, main + + +def run(argv): + out, err = io.StringIO(), io.StringIO() + code = cw.dispatch(COMMANDS, argv, out=out, err=err, prog="cw") + return code, out.getvalue(), err.getvalue() + + +def test_the_three_commands_are_there(): + assert "specs" in cw.mk_parser(COMMANDS).format_help() + code, out, _ = run(["--help"]) + assert code == 0 + for command in ("specs", "help", "parity"): + assert command in out + + +def test_specs_shows_the_arguments_a_function_would_get(): + code, out, err = run(["specs", "cw.egress:confirm"]) + assert code == 0 and err == "" + assert "action" in out and "--skip" in out + + +def test_specs_under_modern_differs_from_argh(): + _, under_argh, _ = run(["specs", "cw.egress:confirm"]) + _, under_modern, _ = run(["specs", "cw.egress:confirm", "-c", "modern"]) + assert under_argh != under_modern + + +def test_an_unknown_convention_is_one_line_and_a_non_zero_exit(): + code, out, err = run(["specs", "cw.egress:confirm", "-c", "nope"]) + assert code == 1 and out == "" + assert ( + err + == "CommandError: no convention named 'nope'. Choose one of: argh, modern.\n" + ) + + +def test_help_prints_the_help_cw_would_print(): + code, out, _ = run(["help", "cw.egress:write_lines"]) + assert code == 0 and out.startswith("usage: write_lines") + + +def test_parity_says_so_until_cw_testing_lands(): + code, _, err = run(["parity"]) + assert code in (0, 1) + if code == 1: + assert "cw.testing" in err + + +def test_parity_delegates_to_cw_testing_when_it_exists(monkeypatch): + """`cw.testing` is a later issue; this pins the delegation it will be plugged into.""" + import types + + fake = types.ModuleType("cw.testing") + seen = {} + + def fake_parity(goldens_dir=None): + seen["dir"] = goldens_dir + return 3 + + fake.parity = fake_parity + monkeypatch.setitem(sys.modules, "cw.testing", fake) + monkeypatch.setattr(cw, "testing", fake, raising=False) + code, out, _ = run(["parity", "-g", "goldens/"]) + assert (code, out, seen) == (3, "", {"dir": "goldens/"}) + + +def test_it_actually_runs_as_a_module(): + """The one thing an in-process test cannot prove: `python -m cw` resolves.""" + result = subprocess.run( + [sys.executable, "-m", "cw", "specs", "cw.egress:write_lines"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert "lines" in result.stdout + + +def test_main_returns_an_exit_code(): + assert main(["specs", "cw.egress:write_lines"]) == 0 From bfb5c6a1f7dbe57f5a7113d75775ca2ba8ba0034 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:56:22 +0200 Subject: [PATCH 4/7] cw.testing, cw.compat, and the parity gate that defines v1 `python -m cw.testing parity` -> "8 shapes / 133 cases: identical", exit 0. Refs #5 #6 #7 #14 #22 #23. cw/testing.py (#5) -- characterize / replay / assert_replay / diff_help / read_cases / normalise_usage / parity. Module-scope imports are exactly argparse, difflib, json, os, re, shlex, subprocess, sys; a test parses the file's own AST to assert it, and another copies the file into a directory with no cw on the path and uses it. `parity` is the one function that imports cw, lazily, inside the body (D4). The capture is fd-level dup2 AND a sys.stdout/sys.stderr re-point at those same descriptors. Both halves are needed: argh binds output_file=sys.stdout at import so redirect_stdout captures nothing from it, and pytest rebinds sys.stdout to something that is not descriptor 1. A first version flushed only the streams it installed, not the ones it displaced -- so recording through a pipe drained argh's buffer after the descriptor was restored and wrote goldens whose every command produced no output. tests/test_corpus_coverage.py:: test_every_shape_records_real_command_output is the guard for exactly that; it is the only check that would have caught it. cw/tests/ (#6, #7) -- eight self-contained shapes reproducing the seven hard repos' grammar, plus a `contract` shape for the egress and error rows no repo shape reaches. 133 argv vectors, each with a comment naming the argh rule it pins. Goldens recorded once from real argh 0.31.3 by misc/record_goldens.py (dev-only, under misc/ so pytest cannot reach it) and committed; re-recording is byte-identical. Recorded text is newline-normalised and heap addresses are scrubbed, so a golden asserts behaviour and not the machine that made it. Parity runs over shapes rather than the seven repos because the spec's version cannot run in cw's CI: three of those repos import argh at module scope, t/ theremin depends on cw, and installing seven fleet packages to test a zero-dependency package defeats the exercise. Verified from a clean venv: `pip install cw-0.0.15-py3-none-any.whl` pulls nothing, and `python -m cw.testing parity` passes from site-packages outside the source tree. 17 of the 20 contract rows are asserted by the gate. Rows 14, 15 and 20 are observable only in a --help body, which the golden format keeps at tier 3 -- recorded, diffed advisorily, never asserted, because --help wraps to COLUMNS and 3.13 reformatted argparse's option column. They are covered by tests/ argh_parity instead, and the coverage test refuses to let a shape claim one. Nine mutations of cw's grammar, egress, ingress and cli each turn the gate red (scratchpad/mutate_gate.py). Two corpus gaps were found that way and closed: no parameter began with `h`, so nothing proved `-h` is stripped, and no declaration carried a falsy nargs. cw/compat.py (#23) -- the eleven measured argh names, each three statements or fewer, each warning once (CW_COMPAT_QUIET=1 silences). The four repairs are tested: a usage error still exits 2 rather than 0; no stream is bound in a signature default; argh's dispatch keywords are split out so output_file= does not reach ArgumentParser; and both group_name= and namespace= are accepted. named/aliases/add_subcommands raise an informative module __getattr__ instead of shipping -- `named` as specified wrote func._cw['name'], which nothing reads. `func_kwargs` is accepted and refused out loud: it is per-command ArgumentParser keywords, not a cw config, and no fleet call site passes it. Windows (#14) -- decision A, normalise, recorded in cw/testing.py's docstring and cw/tests/README.md. Goldens store LF and both sides normalise; parity spawns no subprocess so console shims and code pages never arise; read_cases parses a JSON list as well as a shlex line; every golden is asserted pure ASCII. pyproject: a `test` extra (no argh) for CI, `dev` (argh 0.31.3) for the differential and the recorder; tests/argh_parity skips itself when argh is absent, so the suite is green either way. 790 passed, 2 skipped. cw/testing.py, cw/compat.py, cw/__main__.py at 100% statement coverage; 96% overall. --- conftest.py | 23 + cw/compat.py | 562 +++++++++++++++ cw/testing.py | 874 ++++++++++++++++++++++ cw/tests/README.md | 93 +++ cw/tests/__init__.py | 7 + cw/tests/fixtures.py | 1197 +++++++++++++++++++++++++++++++ cw/tests/goldens/coact.json | 244 +++++++ cw/tests/goldens/contract.json | 200 ++++++ cw/tests/goldens/epythet.json | 196 +++++ cw/tests/goldens/lacing.json | 132 ++++ cw/tests/goldens/priv.json | 259 +++++++ cw/tests/goldens/theremin.json | 232 ++++++ cw/tests/goldens/wads_pack.json | 237 ++++++ cw/tests/goldens/xa.json | 217 ++++++ misc/record_goldens.py | 129 ++++ pyproject.toml | 15 +- tests/argh_parity/conftest.py | 16 + tests/test_cli.py | 25 +- tests/test_compat.py | 478 ++++++++++++ tests/test_corpus_coverage.py | 344 +++++++++ tests/test_import_is_cheap.py | 8 +- tests/test_main.py | 29 +- tests/test_testing.py | 583 +++++++++++++++ 23 files changed, 6084 insertions(+), 16 deletions(-) create mode 100644 conftest.py create mode 100644 cw/compat.py create mode 100644 cw/testing.py create mode 100644 cw/tests/README.md create mode 100644 cw/tests/__init__.py create mode 100644 cw/tests/fixtures.py create mode 100644 cw/tests/goldens/coact.json create mode 100644 cw/tests/goldens/contract.json create mode 100644 cw/tests/goldens/epythet.json create mode 100644 cw/tests/goldens/lacing.json create mode 100644 cw/tests/goldens/priv.json create mode 100644 cw/tests/goldens/theremin.json create mode 100644 cw/tests/goldens/wads_pack.json create mode 100644 cw/tests/goldens/xa.json create mode 100644 misc/record_goldens.py create mode 100644 tests/argh_parity/conftest.py create mode 100644 tests/test_compat.py create mode 100644 tests/test_corpus_coverage.py create mode 100644 tests/test_testing.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..286837c --- /dev/null +++ b/conftest.py @@ -0,0 +1,23 @@ +"""Session-wide pytest setup. + +One thing only: silence `cw.compat`'s deprecation warnings for the run. `cw/compat.py`'s +doctests exercise the shim, and every one of them correctly fires a `DeprecationWarning` -- +which is the module working, not a problem, but sixty-odd of them bury anything that is a +problem. `tests/test_compat.py::TestDeprecation` unsets this and asserts the warnings really +do fire, so silencing them here costs no coverage of the behaviour. +""" + +import os + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def _quiet_compat_deprecations(): + before = os.environ.get("CW_COMPAT_QUIET") + os.environ["CW_COMPAT_QUIET"] = "1" + yield + if before is None: + os.environ.pop("CW_COMPAT_QUIET", None) + else: + os.environ["CW_COMPAT_QUIET"] = before diff --git a/cw/compat.py b/cw/compat.py new file mode 100644 index 0000000..3d0aa1c --- /dev/null +++ b/cw/compat.py @@ -0,0 +1,562 @@ +"""Transitional argh shim: a one-line import change retires a repo. + +**Deprecated from day one.** This module exists so that a repo can stop depending on +LGPL-3.0-or-later ``argh`` *today*, in a diff a reviewer can read in one glance:: + + -import argh + +from cw import compat as argh + +and then migrate to cw's real API on its own schedule. New code should never import it. +Every name warns once, on first use; ``CW_COMPAT_QUIET=1`` silences the lot for a repo that +has decided to live here for a while. + +The surface is the fleet's entire *measured* argh usage -- nothing speculative: +``dispatch_commands`` 34 · ``dispatch_command`` 30 · ``ArghParser`` 27 · ``dispatch`` 19 · +``arg`` 17 · ``add_commands`` 15 · ``NameMappingPolicy`` 4 · ``CommandError`` 4 · +``completion`` 2 · ``confirm`` 1, plus ``set_default_command``. + +Every function here is **three statements or fewer**. That is the design gauge, not a +coding-style preference: :func:`arg` is thin *because* cw's merge ladder already has a +function-attribute tier for it to write into, and if ``@arg`` had needed real work, the +ladder would have been in the wrong shape. The one function with a body worth reading is +:func:`dispatch`, and everything in it is argh-compatibility, not cw. + +Four things this shim does **not** do, each of which a literal reading of argh's signatures +would have got wrong: + +1. **It does not swallow argparse's exit code.** ``argh.dispatch_commands`` is typed + ``-> None``, but in argh the ``SystemExit(2)`` from a usage error propagates out of + ``main()``. ``cw.dispatch`` *returns* that 2 instead, so a literal ``-> None`` shim + would make every migrated console script exit **0** on a bad command line -- across 64 + dispatch-style call sites, breaking any CI step that checks ``$?``. These shims re-raise + a non-zero code. +2. **It does not bind ``sys.stdout`` in a signature default.** argh's + ``output_file: IO = sys.stdout`` is bound at import (``dispatching.py:77``), which is why + ``redirect_stdout`` and pytest's ``capsys`` capture nothing from an argh CLI. That is the + single worst defect in argh's dispatch surface, and putting it back into the shim that + 19 call sites will use would be an odd thing to do. Streams resolve at call time. +3. **It does not forward argh's dispatch keywords to ``ArgumentParser``.** They are split + out explicitly (:data:`_ARGH_DISPATCH_KWARGS`), or ``dispatch_commands(..., + output_file=f)`` would raise ``TypeError: ArgumentParser.__init__() got an unexpected + keyword argument``. +4. **It accepts BOTH ``group_name=`` and ``namespace=``.** argh 0.30 renamed the keyword + with no alias, leaving four fleet call sites passing something nothing reads. Accepting + both revives them -- a change from "would crash" to "works", which is worth knowing + about before you run one. + +And one thing it deliberately refuses to do: ``named``, ``aliases`` and ``add_subcommands`` +have zero uses anywhere in the fleet and are **not** shipped. A module ``__getattr__`` +raises an informative error instead. ``named`` in particular was specified as a shim that +writes ``func._cw['name']`` -- which nothing reads, so it would silently do nothing, which +is worse than the :class:`AttributeError` it existed to prevent. + +**One trap the shim structurally cannot fix**, and which belongs on every migration +checklist: ``from argh import CommandError``. A module that imported the *name* rather than +the module still imports argh after the one-line change, and cw will not catch an exception +class it has never heard of, so ``CommandError: boom`` / exit 1 becomes an unhandled +traceback. Grep for it. + +>>> import io +>>> from cw import compat as argh +>>> def hello(name, *, loudly=False): +... '''Greet someone.''' +... return f'HELLO {name}' if loudly else f'hello {name}' +>>> argh.dispatch_command(hello, ['world'], output_file=io.StringIO()) +>>> argh.dispatch_command(hello, ['world'], output_file=None) +'hello world\\n' +""" + +import argparse +import enum +import functools +import os +import sys +import warnings +from typing import Any, Callable, Mapping, Optional + +import cw +from cw.base import MISSING +from cw.cli import add_commands as _cw_add_commands +from cw.cli import dispatch as _cw_dispatch +from cw.cli import mk_parser as _cw_mk_parser +from cw.cli import run as _cw_run +from cw.cli import set_default_command as _cw_set_default_command + +__all__ = [ + "ArghParser", + "CommandError", + "NameMappingPolicy", + "add_commands", + "arg", + "completion", + "confirm", + "dispatch", + "dispatch_command", + "dispatch_commands", + "set_default_command", +] + +#: argh's expected-failure exception, which is cw's. Not an alias to keep around: it is the +#: same class, so ``except argh.CommandError`` in migrated code catches what cw raises. +CommandError = cw.CommandError + +#: The environment variable that silences this module's deprecation warnings. A repo living +#: in Wave 0 for a while sets it once rather than filtering warnings at every entry point. +QUIET_ENV = "CW_COMPAT_QUIET" + +#: argh's ``dispatch`` keywords, which are **not** ``ArgumentParser`` keywords. Forwarding +#: ``**kw`` blindly to :func:`cw.dispatch` -- whose own ``**parser_kwargs`` go straight to +#: ``ArgumentParser`` -- would turn any one of them into a ``TypeError``. Zero fleet call +#: sites pass one today, but argh's own signature accepts them all, so a migration must not +#: be the thing that discovers it. +_ARGH_DISPATCH_KWARGS = ( + "add_help_command", + "always_flush", + "completion", + "errors_file", + "namespace", + "output_file", + "raw_output", + "skip_unknown_args", +) + +#: Names already warned about, so each one warns once per process rather than once per call. +_WARNED = set() + + +class NameMappingPolicy(str, enum.Enum): + """argh's naming policy, **exported** -- which argh itself never did. + + ``hasattr(argh, 'NameMappingPolicy')`` is ``False``, so + ``illustration/__main__.py:23``'s ``getattr`` guard is a permanent silent no-op and + ``ir/__main__.py:19-34`` needs a triple-nested try/except to reach it. Both work + against this. + + The values are cw's :data:`cw.Convention` ``naming`` strings, so a policy passed here + reaches cw without a translation table in between. + + >>> NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT.value + 'by_name_if_has_default' + """ + + BY_NAME_IF_HAS_DEFAULT = cw.BY_NAME_IF_HAS_DEFAULT + BY_NAME_IF_KWONLY = cw.BY_NAME_IF_KWONLY + + +def _deprecated(func): + """Warn once, on first use, that ``func`` is transitional. Silenced by the env var. + + A decorator rather than a line in each body, so "every shim is three statements" stays + a statement about the shims and not about how they were counted. + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + if func.__name__ not in _WARNED and not os.environ.get(QUIET_ENV): + _WARNED.add(func.__name__) + warnings.warn( + f"cw.compat.{func.__name__} is a transitional argh shim. Migrate to cw's " + f"own API (see cw.dispatch); set {QUIET_ENV}=1 to silence this.", + DeprecationWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapper + + +def _convention_for(name_mapping_policy) -> Any: + """The :data:`cw.Convention` an argh ``name_mapping_policy`` asks for. + + ``None`` means argh's dispatch default, which is cw's default too -- that asymmetry + (argh's ``dispatch_*`` defaults the policy while ``add_commands`` does not) is exactly + what cw removes by having one default everywhere. + + >>> _convention_for(None) is cw.ARGH + True + >>> _convention_for(NameMappingPolicy.BY_NAME_IF_KWONLY).naming + 'by_name_if_kwonly' + """ + import dataclasses + + if name_mapping_policy is None: + return cw.ARGH + naming = getattr(name_mapping_policy, "value", name_mapping_policy) + return dataclasses.replace(cw.ARGH, naming=naming) + + +def _split_kwargs(kwargs: Mapping) -> tuple: + """``(argh dispatch kwargs, ArgumentParser kwargs)`` -- repair 3 above. + + >>> _split_kwargs({'output_file': None, 'prog': 'x'}) + ({'output_file': None}, {'prog': 'x'}) + """ + mine = {k: v for k, v in kwargs.items() if k in _ARGH_DISPATCH_KWARGS} + return mine, {k: v for k, v in kwargs.items() if k not in _ARGH_DISPATCH_KWARGS} + + +def _finish(code: int, captured) -> Optional[str]: + """What an argh dispatch entry point returns, and how it exits -- repairs 1 and 2. + + ``output_file=None`` means "give me the string" (argh's own documented behaviour), and + anything else means "this is a console script": return ``None``, but let a non-zero + exit code out, because that is what argh's propagating ``SystemExit`` did and what + every CI step checking ``$?`` is relying on. + """ + if captured is not None: + return captured.getvalue() + if code: + raise SystemExit(code) + return None + + +def _run_with_streams(build, argv, argh_kwargs: Mapping) -> Optional[str]: + """Build a parser, run it, and honour argh's stream keywords. The shim's one body. + + ``output_file=None`` is argh's "return the string instead of printing" mode, spelled + here as a :class:`io.StringIO` that :func:`_finish` reads back. + """ + import io + + output_file = argh_kwargs.get("output_file", MISSING) + captured = io.StringIO() if output_file is None else None + code = _cw_run( + build(), + argv, + out=captured if captured is not None else _stream_or_none(output_file), + err=_stream_or_none(argh_kwargs.get("errors_file", MISSING)), + completion=argh_kwargs.get("completion", True), + ) + return _finish(code, captured) + + +def _stream_or_none(given): + """``None`` for "cw, resolve this at call time", else the stream the caller passed. + + :data:`cw.MISSING` is what "the caller said nothing" looks like, and it must not become + ``sys.stdout`` here -- resolving it now is precisely the argh defect repair 2 is about. + + >>> _stream_or_none(MISSING) is None + True + """ + return None if given is MISSING else given + + +# ======================================================================================= +# The eleven names +# ======================================================================================= + + +@_deprecated +def dispatch(parser: argparse.ArgumentParser, argv=None, **kwargs) -> Optional[str]: + """argh's ``dispatch``: run a parser that already has its commands. + + Accepts argh's ``output_file`` / ``errors_file`` / ``completion`` and the four keywords + that were verified to have zero fleet uses, so a call site that passes one gets cw's + behaviour rather than a ``TypeError``. ``output_file=None`` returns the output string. + + >>> import io + >>> from cw import compat as argh + >>> parser = argh.ArghParser(prog='demo') + >>> def ping(): + ... return 'pong' + >>> parser.add_commands([ping]) + >>> parser.dispatch(['ping'], output_file=None) + 'pong\\n' + """ + argh_kwargs, parser_kwargs = _split_kwargs(kwargs) + _reject(parser_kwargs, "dispatch") + return _run_with_streams(lambda: parser, argv, argh_kwargs) + + +@_deprecated +def dispatch_commands(functions, argv=None, **kwargs) -> Optional[str]: + """argh's ``dispatch_commands``: build a parser for ``functions`` and run it. + + The fleet's most-used argh name (34 call sites). Unlike argh's, a usage error still + exits 2 rather than 0. + + >>> import io + >>> from cw import compat as argh + >>> def add(a: int, b: int): + ... return a + b + >>> argh.dispatch_commands([add], ['add', '2', '3'], output_file=None) + '5\\n' + """ + argh_kwargs, parser_kwargs = _split_kwargs(kwargs) + policy = parser_kwargs.pop("name_mapping_policy", None) + return _run_with_streams( + lambda: _cw_mk_parser( + functions, convention=_convention_for(policy), **parser_kwargs + ), + argv, + argh_kwargs, + ) + + +@_deprecated +def dispatch_command(function, argv=None, *, old_name_mapping_policy=True, **kwargs): + """argh's ``dispatch_command``: one function, no command word. + + ``old_name_mapping_policy`` is accepted and ignored, as it is in argh when it is + ``True`` -- which is the only value any fleet call site passes, and is cw's default + everywhere. + """ + argh_kwargs, parser_kwargs = _split_kwargs(kwargs) + policy = parser_kwargs.pop("name_mapping_policy", None) + return _run_with_streams( + lambda: _cw_mk_parser( + function, convention=_convention_for(policy), **parser_kwargs + ), + argv, + argh_kwargs, + ) + + +@_deprecated +def add_commands( + parser: argparse.ArgumentParser, + functions, + *, + name_mapping_policy=None, + group_name: Optional[str] = None, + namespace: Optional[str] = None, + group_kwargs: Optional[Mapping] = None, + namespace_kwargs: Optional[Mapping] = None, + func_kwargs: Optional[Mapping] = None, +) -> None: + """argh's ``add_commands``, accepting the pre-0.30 spelling as well. + + ``namespace=`` / ``namespace_kwargs=`` were renamed to ``group_name=`` / + ``group_kwargs=`` in argh 0.30 with no alias, which left ``wads/__init__.py:98``, + ``hedger/__main__.py:26``, ``ke/__main__.py:24`` and ``ek/__main__.py:24`` passing a + keyword nothing reads. Accepting both makes those four call sites work -- flag it in + the migration notes, because "would crash" becoming "works" is still a change. + + >>> import argparse + >>> from cw import compat as argh + >>> def status(): + ... '''Say how things are.''' + >>> parser = argparse.ArgumentParser(prog='priv') + >>> argh.add_commands(parser, [status], namespace='git_ops') + >>> parser.format_usage() + 'usage: priv [-h] {git_ops} ...\\n' + """ + _reject_func_kwargs(func_kwargs) + _cw_add_commands( + parser, + functions, + group_name=group_name, + namespace=namespace, + group_kwargs=group_kwargs, + namespace_kwargs=namespace_kwargs, + convention=_convention_for(name_mapping_policy), + ) + + +@_deprecated +def set_default_command( + parser, function, *, name_mapping_policy=None, **kwargs +) -> None: + """argh's ``set_default_command``: make ``function`` the parser's only command.""" + _cw_set_default_command( + parser, function, convention=_convention_for(name_mapping_policy), **kwargs + ) + + +def arg(*flags: str, **add_argument_kwargs) -> Callable: + """argh's ``@arg``: declare one argument's particulars on the function itself. + + Three statements, because cw's merge ladder already has a function-attribute tier + (``func._cw['params'][param]``) for this to write into. ``_cw`` is a plain attribute on + purpose: it is the documented contract, and it is what lets a repo declare CLI details + without importing cw at all. + + Not deprecated by a warning, unlike the rest of this module -- it is a decorator + evaluated at import time, so warning on it would fire before anybody could act, and it + is the one name here whose target (a plain attribute) is a supported cw contract rather + than a shim. + + >>> from cw import compat as argh + >>> @argh.arg('-i', '--ignore', nargs='*') + ... def quickstart(project_dir, *, ignore=None): + ... '''Start a project.''' + >>> quickstart._cw['params']['ignore'] + {'nargs': '*', 'flags': ['-i', '--ignore']} + """ + + def decorate(func): + param = _param_name_of(flags) + # Innermost decorator runs first but must read last: argh's own `insert(0, ...)`. + declared = { + param: dict(add_argument_kwargs, flags=list(flags)), + **getattr(func, "_cw", {}).get("params", {}), + } + func._cw = dict(getattr(func, "_cw", {}), params=declared) + return func + + return decorate + + +@_deprecated +def confirm(action: str, default=None, skip=False, **kwargs): + """argh's ``confirm``: a yes/no prompt, with argh's exact wording. + + >>> import io + >>> from cw import compat as argh + >>> argh.confirm('Go', skip=True) is None + True + """ + return cw.confirm(action, default=default, skip=skip, **kwargs) + + +class ArghParser(argparse.ArgumentParser): + """argh's ``ArgumentParser`` subclass -- 27 fleet call sites. + + The three convenience methods, and nothing else. Note that this is the **only** class + in cw that subclasses :class:`argparse.ArgumentParser`, and it exists solely because + those 27 call sites already do. :func:`cw.mk_parser` returns a plain + ``ArgumentParser``, which is what keeps ``argcomplete.autocomplete`` -- typed against + ``argparse.ArgumentParser`` -- working for the ten fleet files marked + ``# PYTHON_ARGCOMPLETE_OK``. + + >>> from cw import compat as argh + >>> def ping(): + ... return 'pong' + >>> parser = argh.ArghParser(prog='demo') + >>> parser.add_commands([ping]) + >>> parser.dispatch(['ping'], output_file=None) + 'pong\\n' + """ + + def add_commands(self, *args, **kwargs) -> None: + """:func:`cw.compat.add_commands`, on this parser.""" + return add_commands(self, *args, **kwargs) + + def set_default_command(self, *args, **kwargs) -> None: + """:func:`cw.compat.set_default_command`, on this parser.""" + return set_default_command(self, *args, **kwargs) + + def dispatch(self, *args, **kwargs): + """:func:`cw.compat.dispatch`, on this parser.""" + return dispatch(self, *args, **kwargs) + + +class _Completion: + """``argh.completion``, the submodule two fleet files reach into. + + A namespace rather than a module because there is exactly one function behind it, and + a one-function module would be a file whose only content is an import. + """ + + @staticmethod + def autocomplete(parser) -> bool: + """``argh.completion.autocomplete``: offer ``parser`` to argcomplete.""" + return cw.enable_completion(parser) + + +#: ``argh.completion.autocomplete(parser)`` keeps working after the import swap. +completion = _Completion() + + +# ======================================================================================= +# Helpers, and the three names that are deliberately absent +# ======================================================================================= + + +def _param_name_of(flags) -> str: + """``('-i', '--ignore') -> 'ignore'`` -- argh's ``naive_guess_func_arg_name``. + + >>> _param_name_of(('-i', '--ignore')), _param_name_of(('project',)) + ('ignore', 'project') + """ + if len(flags) == 1: + return flags[0].lstrip("-").replace("-", "_") + for flag in flags: + if flag.startswith("--"): + return flag[2:].replace("-", "_") + raise ValueError( + f"cannot guess which parameter {flags} refers to. argh guesses from the long " + f"flag, so give one -- @arg('-i', '--ignore') rather than @arg('-i')." + ) + + +def _reject_func_kwargs(func_kwargs) -> None: + """Refuse a non-empty ``func_kwargs`` rather than mis-translating it. + + argh's ``func_kwargs`` is a mapping of **ArgumentParser** keywords applied to every + subcommand's parser (``assembling.py:630-635``) -- *not* ``add_argument`` keywords, and + therefore not a cw ``config``, whose leaves are per-parameter. cw has no per-command + parser-keyword channel, and inventing one for a keyword **no fleet call site passes** + would be shipping a feature to nobody. + + So it stays in the signature -- a call site that names it must not fail at import-shim + time on a ``TypeError`` about an unexpected keyword -- and says so plainly if used. The + first repo that actually needs it will produce a real requirement instead of a guess. + """ + if func_kwargs: + raise NotImplementedError( + "cw.compat.add_commands accepts argh's `func_kwargs` but does not implement " + "it: it is a mapping of ArgumentParser keywords applied to every subcommand, " + "which cw has no channel for, and no fleet call site passes it. If you need " + "it, build the parser yourself -- cw.mk_parser returns a plain " + "argparse.ArgumentParser -- and open an issue on i2mint/cw." + ) + + +def _reject(parser_kwargs: Mapping, where: str) -> None: + """Refuse leftover keywords with a message that says which call is wrong.""" + if parser_kwargs: + raise TypeError( + f"cw.compat.{where}() got unexpected keyword argument(s) " + f"{', '.join(sorted(parser_kwargs))}. A parser that already exists takes no " + f"build-time keywords; pass them to ArgumentParser() or to cw.mk_parser()." + ) + + +#: argh names with **zero** uses anywhere in the fleet, and why each one is not shipped. +#: A module ``__getattr__`` raises these rather than shipping a shim, because a shim that +#: does the wrong thing quietly is worse than the AttributeError it replaces. +_NOT_SHIPPED = { + "named": ( + "argh's `named` renames a command. cw takes the name from a mapping KEY -- " + "`{'load': do_load}` -- which also lets two commands share a name in different " + "groups. (The shim originally specified for this wrote func._cw['name'], which " + "nothing reads: it would have silently done nothing.)" + ), + "aliases": ( + "argh's `aliases` adds alternative command names. cw has no equivalent in v1; " + "a mapping can name the same callable twice if you need one." + ), + "add_subcommands": ( + "argh's `add_subcommands` is `add_commands(..., group_name=...)` with the " + "arguments in a different order. Use cw.compat.add_commands(parser, funcs, " + "group_name='...')." + ), + "EntryPoint": "argh's `EntryPoint` is a registry object, not a function. Use a mapping.", + "wrap_errors": "Zero fleet uses. Raise cw.CommandError from the command instead.", + "raw_output": "Not a name -- it is a `dispatch` keyword, and it is accepted as one.", + "ArghNamespace": "An argh internal. cw's parsers produce a plain argparse.Namespace.", +} + + +def __getattr__(name: str): + """Explain the deliberately-absent names, rather than failing with a bare message. + + >>> from cw import compat as argh + >>> try: + ... argh.named + ... except AttributeError as exc: + ... print('does not ship `named`' in str(exc), 'mapping KEY' in str(exc)) + True True + >>> argh.no_such_thing_at_all + Traceback (most recent call last): + ... + AttributeError: module 'cw.compat' has no attribute 'no_such_thing_at_all' + """ + if name in _NOT_SHIPPED: + raise AttributeError( + f"cw.compat does not ship `{name}`, and it has zero uses across the fleet's " + f"argh-touching files. {_NOT_SHIPPED[name]}" + ) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/cw/testing.py b/cw/testing.py new file mode 100644 index 0000000..65a8ffc --- /dev/null +++ b/cw/testing.py @@ -0,0 +1,874 @@ +"""Record a CLI's behaviour before a migration and assert it after. + +This file is **standalone** (D4). Its module-level imports are ``argparse``, ``difflib``, +``json``, ``os``, ``re``, ``shlex``, ``subprocess`` and ``sys`` -- stdlib, all of it, and no +``cw`` anywhere. That is not tidiness; it is the whole point. The fleet has 22 repos whose +CLI is being deleted and 35 that are argparse-only and will never depend on ``cw``, and all +of them want the same thing: *proof that the command line did not change*. Copy this one +file into such a repo and it works. + +Three entry points, in the order you meet them: + +``characterize(prog, cases)`` + Run a **real console script** as a subprocess against a list of ``argv`` vectors and + record what a shell would see. Do this **before** you touch anything. + +``replay(golden)`` / ``assert_replay(golden)`` + Run it again and diff. Do this **after**. + +``parity()`` + cw's own gate: replay the committed argh-recorded goldens for the seven hard-case + shapes through cw. This one -- and only this one -- imports ``cw``, lazily, inside the + function, so the standalone guarantee above survives. + +The golden format: one format, three tiers +------------------------------------------ + +===== =========================================================== ================= +tier content treatment +===== =========================================================== ================= +1 ``argv``, ``returncode``, full ``stdout``, full ``stderr`` **asserted** +2 the normalised ``usage:`` line **asserted** +3 the full ``--help`` body snapshot only +===== =========================================================== ================= + +Tier 3 is never asserted because ``--help`` wraps to ``COLUMNS`` and a big CLI's help is +hundreds of lines; asserting it would produce false failures forever. It is diffed +advisorily by :func:`diff_help`. Tier 2 does most of the work tier 3 looks like it would: +argparse's ``usage:`` line names **every** option a parser has, so a lost flag, a lost short +flag or a changed ``nargs`` all show up there, whitespace-collapsed and width-independent. + +Windows (ADR: option A, "normalise") +------------------------------------ + +Recorded text is stored newline-normalised (``\\r\\n`` and ``\\r`` both become ``\\n``) and +every comparison normalises both sides, so a golden recorded on a Mac asserts cleanly on a +Windows runner. The subprocess environment pins ``COLUMNS``, ``PYTHONUTF8``, +``PYTHONIOENCODING``, ``PYTHONHASHSEED`` and ``TERM`` so the bytes are reproducible rather +than merely comparable. :func:`read_cases` reads a JSON-list form as well as a ``shlex`` +line, because ``shlex`` is POSIX-only and a Windows user must never need it. And +:func:`parity` spawns no subprocess at all -- it runs in-process against shipped fixtures -- +so the ``.exe`` console-script shim and the cp1252 console never enter the picture. + + >>> normalise_text('a\\r\\nb\\r\\n') + 'a\\nb\\n' + >>> normalise_usage('usage: prog [-h]\\n [--wrapped]\\n\\nSome description.') + 'usage: prog [-h] [--wrapped]' +""" + +import argparse +import difflib +import json +import os +import re +import shlex +import subprocess +import sys + +__all__ = [ + "GOLDEN_VERSION", + "RECORDING_ENV", + "assert_replay", + "capture", + "characterize", + "compare_case", + "diff_help", + "load_golden", + "main", + "normalise_text", + "normalise_usage", + "parity", + "pinned_env", + "read_cases", + "replay", +] + +#: Bumped when the golden JSON schema changes incompatibly. A golden that does not carry +#: this key is not a golden, and saying so beats a :class:`KeyError` three frames later. +GOLDEN_VERSION = 1 + +#: Terminal width pinned during recording. ``argparse`` wraps help and usage text to +#: ``shutil.get_terminal_size()``, which reads ``COLUMNS`` first -- so pinning it is what +#: makes recorded text reproducible on a machine with a different terminal. +DFLT_COLUMNS = "100" + +#: The environment pinned around every recorded and replayed run. Everything here exists to +#: remove a source of variation between two machines, not to make the CLI behave specially. +RECORDING_ENV = { + "COLUMNS": DFLT_COLUMNS, + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8", + "PYTHONHASHSEED": "0", + "TERM": "dumb", + "NO_COLOR": "1", +} + +#: Environment variables actively *removed* before a recorded run: they make argcomplete or +#: a pager take over, which is not the behaviour anybody wants to pin. +UNSET_ENV = ("_ARGCOMPLETE", "COMP_LINE", "COMP_POINT", "PAGER", "LINES") + +#: An ``argv`` containing one of these asks for a ``--help`` body, which is tier 3. +HELP_FLAGS = ("-h", "--help") + +#: What a tier-1 case asserts, and what a tier-3 case asserts. The difference is the whole +#: of the three-tier rule; there is no other place in this file where a tier is consulted. +TIER1_FIELDS = ("returncode", "stdout", "stderr", "usage") +TIER3_FIELDS = ("returncode", "usage") + +#: Seconds a single recorded case may take before it is reported as a failure. +DFLT_TIMEOUT = 30 + +#: Where :func:`parity` looks when it is not told. Ships inside the package, so the gate +#: runs from an installed ``cw`` and not only from a checkout. +DFLT_GOLDENS_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "tests", "goldens" +) + + +# --------------------------------------------------------------------------------------- +# Normalisation -- the two functions every comparison goes through +# --------------------------------------------------------------------------------------- + +#: The ``usage:`` block: from a line starting ``usage:`` up to the first blank line. +_USAGE = re.compile(r"^usage:.*?(?=\n[ \t]*\n|\Z)", re.MULTILINE | re.DOTALL) + + +def normalise_text(text: str) -> str: + """Newline-normalise ``text`` so a Mac recording asserts on a Windows runner. + + This is the whole of the Windows decision (option A) as it applies to tier 1. It is + deliberately the *only* normalisation applied to recorded output -- anything else would + be forgiving a real difference. + + >>> normalise_text('one\\r\\ntwo\\rthree\\n') + 'one\\ntwo\\nthree\\n' + >>> normalise_text(None) is None + True + """ + if text is None: + return None + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def normalise_usage(text: str) -> str: + """The ``usage:`` block of ``text``, whitespace-collapsed and width-independent. + + ``argparse`` wraps the usage block to the terminal width, so the same parser prints it + over two lines at ``COLUMNS=100`` and five at ``COLUMNS=50``. Collapsing every run of + whitespace to one space makes the two equal while keeping what actually matters: which + options exist, in which order, with which metavars. + + >>> normalise_usage('usage: prog [-h] [-v]\\n\\npositional arguments:\\n x') + 'usage: prog [-h] [-v]' + >>> normalise_usage('usage: prog\\n [--a]\\n [--b] x') + 'usage: prog [--a] [--b] x' + + Text with no usage block at all -- a command that just printed its result -- normalises + to the empty string rather than raising, because "this case shows no usage line" is a + fact worth asserting too: + + >>> normalise_usage('hello world\\n') + '' + """ + match = _USAGE.search(normalise_text(text) or "") + return " ".join(match.group(0).split()) if match else "" + + +#: A CPython object repr's memory address: ````. +_ADDRESS = re.compile(r"0x[0-9a-fA-F]{4,16}") + + +def scrub_addresses(text: str) -> str: + """Replace object-repr memory addresses with a placeholder. + + The one **content** normalisation in this module, and it exists because a golden that + records ```` asserts the heap layout of the machine + that recorded it. It is applied at record time as well as at replay time, so the + committed golden shows what is actually being asserted rather than hiding it in the + comparator. + + >>> scrub_addresses('') + '' + """ + return _ADDRESS.sub("0xADDR", text) if text else text + + +def _usage_of(stdout: str, stderr: str) -> str: + """The normalised ``usage:`` line, from wherever the CLI put it. + + ``--help`` writes it to stdout and a usage error writes it to stderr, and a golden + should not have to know which happened. + """ + return normalise_usage(stdout) or normalise_usage(stderr) + + +# --------------------------------------------------------------------------------------- +# Cases -- the argv vectors, in either of two spellings +# --------------------------------------------------------------------------------------- + + +def _as_argv(case) -> list: + """One case as a list of strings, from either spelling. + + A JSON list is the cross-platform form; a ``shlex`` line is the convenient one. + + >>> _as_argv('quickstart . --ignore') + ['quickstart', '.', '--ignore'] + >>> _as_argv('["quickstart", ".", "--ignore"]') + ['quickstart', '.', '--ignore'] + >>> _as_argv(['already', 'split']) + ['already', 'split'] + """ + if not isinstance(case, str): + return [str(part) for part in case] + stripped = case.strip() + if stripped.startswith("["): + return [str(part) for part in json.loads(stripped)] + return shlex.split(stripped) + + +def read_cases(path) -> list: + """The ``argv`` vectors in a cases file, one per line. + + Each line is either a JSON list -- ``["a", "b c"]`` -- or a POSIX ``shlex`` line. + Blank lines and ``#`` comments are skipped, and a line of ``[]`` (or nothing but + whitespace before a comment) is *not* a way to spell the empty argv: use ``[]`` + explicitly. + + Both spellings exist for one reason: ``shlex`` is POSIX-only, so a Windows user writing + a corpus must have a form that never reaches it. + """ + with open(path, "r", encoding="utf-8") as stream: + lines = stream.read().splitlines() + return [ + _as_argv(line) + for line in lines + if line.strip() and not line.lstrip().startswith("#") + ] + + +def _tier_of(argv, *, help_flags=HELP_FLAGS) -> int: + """``3`` if ``argv`` asks for a help body, else ``1``. + + >>> _tier_of(['spawn', '-x']), _tier_of(['spawn', '--help']) + (1, 3) + """ + return 3 if any(flag in argv for flag in help_flags) else 1 + + +# --------------------------------------------------------------------------------------- +# Running a case -- in a subprocess (characterize/replay) or in-process (parity) +# --------------------------------------------------------------------------------------- + + +def _env_for(env=None) -> dict: + """``os.environ`` with the recording pins applied and the disruptors removed.""" + resolved = dict(os.environ) + resolved.update(RECORDING_ENV) + resolved.update(env or {}) + for name in UNSET_ENV: + resolved.pop(name, None) + return resolved + + +class pinned_env: + """Context manager applying :data:`RECORDING_ENV` to the **current** process. + + :func:`characterize` hands the pins to a subprocess, where they belong. :func:`parity` + runs in-process and needs the same pins applied here instead -- ``COLUMNS`` above all, + since that is what ``argparse`` wraps to. + + >>> with pinned_env(): + ... os.environ['COLUMNS'] + '100' + """ + + def __init__(self, env=None): + self.env = dict(RECORDING_ENV, **(env or {})) + self._saved = {} + + def __enter__(self): + for name in list(self.env) + list(UNSET_ENV): + self._saved[name] = os.environ.get(name) + os.environ.update(self.env) + for name in UNSET_ENV: + os.environ.pop(name, None) + return self + + def __exit__(self, *exc_info): + for name, value in self._saved.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + return False + + +def _exit_status(exc: SystemExit) -> tuple: + """``(returncode, extra_stderr)`` -- the exit the interpreter would give ``exc``. + + ``SystemExit(None)`` is 0, ``SystemExit(int)`` is that int, and ``SystemExit(str)`` + prints the string to stderr and exits 1. Reproducing all three is what lets an + in-process run be compared against a subprocess recording. + + >>> _exit_status(SystemExit()), _exit_status(SystemExit(3)), _exit_status(SystemExit('bye')) + ((0, ''), (3, ''), (1, 'bye\\n')) + """ + code = exc.code + if code is None: + return 0, "" + if isinstance(code, int): + return code, "" + return 1, f"{code}\n" + + +def capture(call) -> dict: + """Run ``call()`` with file descriptors 1 and 2 captured; report what a shell would see. + + Capture is at the **file-descriptor** level, and ``sys.stdout`` / ``sys.stderr`` are + then re-pointed at those same descriptors. Both halves are load-bearing, for opposite + reasons: + + * the descriptor half, because argh binds ``output_file=sys.stdout`` at import + (``dispatching.py:77``) and writes through that original object forever after -- a + ``redirect_stdout`` around it captures nothing at all; and + * the ``sys`` half, because a *caller* may already have rebound ``sys.stdout`` to + something that is not descriptor 1. pytest does exactly this, and without the second + half every command's output would land in pytest's buffer and the recording would + come back empty. + + Using one mechanism for both sides of a diff is what makes the diff mean something, so + it has to be the mechanism that works in every process either side might run in. + + ``call`` takes no arguments and returns an exit code, or raises :class:`SystemExit`. An + exception that escapes is reported as ``returncode`` 1 with its ``Type: message`` line + on stderr -- what the interpreter shows, minus a traceback whose file paths would differ + on every machine. + + The example writes to the descriptor rather than through ``print`` for a reason worth + knowing: ``doctest`` rebinds ``sys.stdout`` to collect a doctest's own output, so a + ``print`` here would be intercepted by ``doctest`` and never reach the descriptor at + all. Real CLIs write to ``sys.stdout``, which *is* the descriptor -- which is why this + mechanism works where it matters and looks awkward only here. + + >>> capture(lambda: os.write(1, b'hi\\n') and 0) + {'returncode': 0, 'stdout': 'hi\\n', 'stderr': ''} + >>> capture(lambda: sys.exit('bye')) + {'returncode': 1, 'stdout': '', 'stderr': 'bye\\n'} + >>> capture(lambda: 1 // 0)['stderr'] + 'ZeroDivisionError: integer division or modulo by zero\\n' + """ + import tempfile # local: only this function needs it, and D4 counts module imports + + saved_streams = (sys.stdout, sys.stderr) + _flush(sys.stdout, sys.stderr) + with tempfile.TemporaryFile() as out_file, tempfile.TemporaryFile() as err_file: + saved_fds = (os.dup(1), os.dup(2)) + trailer = "" + try: + os.dup2(out_file.fileno(), 1) + os.dup2(err_file.fileno(), 2) + sys.stdout = _stream_on_fd(1) + sys.stderr = _stream_on_fd(2) + try: + returncode = call() or 0 + except SystemExit as exc: + returncode, trailer = _exit_status(exc) + except BaseException as exc: # noqa: BLE001 - a crash IS the recorded fact + trailer = f"{type(exc).__name__}: {exc}\n" + returncode = 1 + finally: + # Flush the streams we installed AND the ones we displaced, in that order. + # The displaced ones matter and are easy to forget: argh writes through the + # `sys.stdout` object it captured at import, which is block-buffered whenever + # descriptor 1 is a pipe or a redirect rather than a terminal. Skip this flush + # and its buffer drains *after* the descriptor is restored -- so recording + # through a pipe silently produces empty goldens, and only through a terminal + # produces real ones. That is a difference no reviewer would ever suspect. + _flush(sys.stdout, sys.stderr, *saved_streams) + sys.stdout.close() + sys.stderr.close() + sys.stdout, sys.stderr = saved_streams + for saved, fd in zip(saved_fds, (1, 2)): + os.dup2(saved, fd) + os.close(saved) + out_file.seek(0) + err_file.seek(0) + stdout = out_file.read().decode("utf-8", "replace") + stderr = err_file.read().decode("utf-8", "replace") + trailer + return { + "returncode": returncode, + "stdout": normalise_text(stdout), + "stderr": normalise_text(stderr), + } + + +def _stream_on_fd(fd: int): + """A text stream writing to ``fd``, which does not own it. + + ``closefd=False`` matters: :func:`capture` closes these to flush them, and closing the + descriptor with them would take the process's real stdout down with it. + """ + return open(fd, "w", encoding="utf-8", errors="replace", closefd=False) + + +def _flush(*streams) -> None: + """Flush what can be flushed. A stream already closed by the command is not an error.""" + for stream in streams: + try: + stream.flush() + except (ValueError, OSError): # already closed, or not a real stream + pass + + +def _as_command(prog) -> list: + """``prog`` as a command list, from either spelling. + + A list is the cross-platform form and is taken as-is. A string is ``shlex``-split, + which is POSIX-only -- hence the list form, and hence this docstring. + + >>> _as_command('python -m cw') + ['python', '-m', 'cw'] + >>> _as_command(['python', '-m', 'cw']) + ['python', '-m', 'cw'] + """ + return shlex.split(prog) if isinstance(prog, str) else [str(part) for part in prog] + + +def _run_subprocess(command, argv, *, env, cwd, timeout) -> dict: + """One case, run as a real subprocess. The heart of the standalone half.""" + try: + done = subprocess.run( + command + list(argv), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + cwd=cwd, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return { + "returncode": None, + "stdout": "", + "stderr": f"cw.testing: timed out after {timeout}s\n", + } + return { + "returncode": done.returncode, + "stdout": normalise_text(done.stdout), + "stderr": normalise_text(done.stderr), + } + + +def _record(run_one, argv, *, help_flags=HELP_FLAGS) -> dict: + """A full case record -- the three tiers -- from a ``(argv) -> outcome`` callable.""" + outcome = run_one(argv) + case = {"argv": list(argv), "tier": _tier_of(argv, help_flags=help_flags)} + case.update(outcome) + for stream in ("stdout", "stderr"): + case[stream] = scrub_addresses(case[stream]) + case["usage"] = _usage_of(case["stdout"], case["stderr"]) + return case + + +# --------------------------------------------------------------------------------------- +# characterize -- record what a real console script does today +# --------------------------------------------------------------------------------------- + + +def characterize( + prog, + cases, + *, + out_path=None, + env=None, + cwd=None, + timeout=DFLT_TIMEOUT, + note=None, +) -> dict: + """Record a real console script's behaviour against ``cases``, as a golden. + + Args: + prog: The command, as a list (``['python', '-m', 'priv']``) or a POSIX string. + The list form is the cross-platform one. + cases: ``argv`` vectors -- a list of lists, a list of ``shlex`` strings, or the + path of a cases file readable by :func:`read_cases`. + out_path: Where to write the golden JSON. ``None`` returns it without writing. + env: Extra environment for the subprocess, over :data:`RECORDING_ENV`. + cwd: Working directory for the subprocess. + timeout: Seconds one case may take. + note: Free text stored in the golden -- the commit you recorded at, say. + + Returns: + The golden, as a plain dict. JSON-serialisable, with sorted keys when written, so + re-recording an unchanged CLI produces a byte-identical file. + + Do this **before** the migration, and commit the result. That is the entire method: + everything else in this module compares against what you recorded here. + """ + command = _as_command(prog) + if isinstance(cases, (str, bytes, os.PathLike)): + cases = read_cases(cases) + resolved = _env_for(env) + + def run_one(argv): + return _run_subprocess(command, argv, env=resolved, cwd=cwd, timeout=timeout) + + golden = { + "cw_golden": GOLDEN_VERSION, + "prog": command, + "env": dict(RECORDING_ENV, **(env or {})), + "newlines": "lf", + "recorded_with": _provenance(), + "note": note, + "cases": [_record(run_one, _as_argv(case)) for case in cases], + } + if out_path is not None: + write_golden(golden, out_path) + return golden + + +def _provenance() -> dict: + """Who recorded this golden and with what -- so a stale one can be spotted.""" + import platform # local: provenance is the only caller, and D4 counts module imports + + return { + "python": platform.python_version(), + "platform": platform.system(), + "implementation": platform.python_implementation(), + } + + +def write_golden(golden: dict, path) -> None: + """Write ``golden`` to ``path``: sorted keys, two-space indent, trailing newline. + + Stability is the requirement, not prettiness. Re-recording an unchanged CLI must + produce a file ``git diff`` calls empty, or nobody will ever re-record. + """ + directory = os.path.dirname(os.path.abspath(path)) + os.makedirs(directory, exist_ok=True) + with open(path, "w", encoding="utf-8", newline="\n") as stream: + json.dump(golden, stream, indent=2, sort_keys=True, ensure_ascii=False) + stream.write("\n") + + +def load_golden(golden) -> dict: + """A golden, from a dict or the path of one, validated enough to fail usefully. + + >>> load_golden({'cw_golden': 1, 'cases': []})['cases'] + [] + >>> load_golden({'cases': []}) + Traceback (most recent call last): + ... + ValueError: not a cw golden (no 'cw_golden' key): a dict + """ + if isinstance(golden, (str, bytes, os.PathLike)): + with open(golden, "r", encoding="utf-8") as stream: + loaded = json.load(stream) + where = str(golden) + else: + loaded, where = golden, "a dict" + if not isinstance(loaded, dict) or "cw_golden" not in loaded: + raise ValueError(f"not a cw golden (no 'cw_golden' key): {where}") + if loaded["cw_golden"] != GOLDEN_VERSION: + raise ValueError( + f"golden {where} is format version {loaded['cw_golden']}, " + f"but this cw.testing reads version {GOLDEN_VERSION}. Re-record it." + ) + return loaded + + +# --------------------------------------------------------------------------------------- +# compare -- the one place a difference is decided +# --------------------------------------------------------------------------------------- + + +def compare_case(recorded: dict, fresh: dict) -> str: + """The empty string if ``fresh`` matches ``recorded``, else a readable diff. + + Which fields are compared is the tier rule and nothing else: tier 1 asserts the + return code and both streams in full, tier 3 asserts the return code and the normalised + ``usage:`` line and leaves the ``--help`` body to :func:`diff_help`. + + >>> a = {'tier': 1, 'returncode': 0, 'stdout': 'hi\\n', 'stderr': '', 'usage': ''} + >>> compare_case(a, dict(a)) + '' + >>> print(compare_case(a, dict(a, stdout='ho\\n'))) + stdout: + - hi + + ho + """ + fields = TIER1_FIELDS if recorded.get("tier", 1) == 1 else TIER3_FIELDS + chunks = [] + for field in fields: + want, got = recorded.get(field), fresh.get(field) + if field == "returncode": + if want != got: + chunks.append(f"returncode:\n - {want!r}\n + {got!r}") + continue + want, got = normalise_text(want), normalise_text(got) + if want != got: + chunks.append(f"{field}:\n" + _text_diff(want, got)) + return "\n".join(chunks) + + +def _text_diff(want, got) -> str: + """A unified-ish diff of two blobs, indented so it reads under a field name.""" + lines = difflib.ndiff( + (want or "").splitlines(keepends=False), (got or "").splitlines(keepends=False) + ) + return "\n".join(f" {line}" for line in lines if not line.startswith("? ")) + + +# --------------------------------------------------------------------------------------- +# replay -- assert the console script still does what it did +# --------------------------------------------------------------------------------------- + + +def _expected(expect_diff) -> set: + """``expect_diff`` as a set of argv tuples, from any of the spellings a caller uses.""" + return {tuple(_as_argv(case)) for case in (expect_diff or ())} + + +def replay( + golden, + *, + prog=None, + env=None, + cwd=None, + expect_diff=(), + timeout=DFLT_TIMEOUT, +) -> list: + """Re-run a golden's cases and report, per case, whether the behaviour survived. + + Args: + golden: A golden dict, or the path of one. + prog: The command to run now. ``None`` reuses the golden's own ``prog``, which is + what you want after an in-place migration and not what you want when the entry + point moved. + env, cwd, timeout: As :func:`characterize`. + expect_diff: ``argv`` vectors whose behaviour you **intend** to have changed. They + are reported as ``expected-diff``, never as a failure -- and an ``expect_diff`` + entry that turns out identical is reported as ``unexpected-match``, because a + migration note claiming a break that did not happen is also wrong. + + Returns: + One dict per case: ``argv``, ``status`` and ``diff``. + + Statuses are ``identical``, ``differs``, ``expected-diff`` and ``unexpected-match``. + :func:`assert_replay` is the version that raises. + """ + golden = load_golden(golden) + command = _as_command(prog if prog is not None else golden["prog"]) + resolved = _env_for(dict(golden.get("env") or {}, **(env or {}))) + intended = _expected(expect_diff) + results = [] + for recorded in golden["cases"]: + argv = list(recorded["argv"]) + fresh = _record( + lambda a: _run_subprocess( + command, a, env=resolved, cwd=cwd, timeout=timeout + ), + argv, + ) + results.append(_verdict(recorded, fresh, intended)) + return results + + +def _verdict(recorded: dict, fresh: dict, intended: set) -> dict: + """One case's outcome, with ``expect_diff`` applied in both directions.""" + argv = list(recorded["argv"]) + diff = compare_case(recorded, fresh) + if tuple(argv) in intended: + status = "expected-diff" if diff else "unexpected-match" + else: + status = "differs" if diff else "identical" + return {"argv": argv, "status": status, "diff": diff} + + +def assert_replay(golden, **kwargs) -> None: + """:func:`replay`, raising :class:`AssertionError` with a readable diff on any failure. + + ``expected-diff`` cases are reported in the message when something *else* failed, so a + reader can see what was intended alongside what was not, but they never cause a raise. + An ``unexpected-match`` does: it means the migration note is wrong. + """ + results = replay(golden, **kwargs) + bad = [r for r in results if r["status"] in ("differs", "unexpected-match")] + if not bad: + return + report = "\n\n".join(_report_line(r) for r in bad) + raise AssertionError( + f"{len(bad)} of {len(results)} cases changed behaviour:\n\n{report}" + ) + + +def _report_line(result: dict) -> str: + """One failing case, formatted for a human reading a test failure.""" + argv = " ".join(shlex.quote(part) for part in result["argv"]) or "(no arguments)" + if result["status"] == "unexpected-match": + return f"$ {argv}\n expect_diff listed this case, but it is unchanged." + return f"$ {argv}\n{result['diff']}" + + +def diff_help(golden, *, prog=None, timeout=DFLT_TIMEOUT, env=None, cwd=None) -> str: + """The advisory tier-3 diff: how every ``--help`` body changed. Never asserted. + + ``--help`` output is the right thing for a human to review after a migration and the + wrong thing for a machine to assert: it wraps to ``COLUMNS`` and it changes for reasons + nobody minds. This returns it as text for a review queue, and returns ``''`` when + nothing moved. + """ + golden = load_golden(golden) + command = _as_command(prog if prog is not None else golden["prog"]) + resolved = _env_for(dict(golden.get("env") or {}, **(env or {}))) + chunks = [] + for recorded in (c for c in golden["cases"] if c.get("tier") == 3): + argv = list(recorded["argv"]) + fresh = _run_subprocess(command, argv, env=resolved, cwd=cwd, timeout=timeout) + diff = "\n".join( + difflib.unified_diff( + (recorded["stdout"] or "").splitlines(), + (normalise_text(fresh["stdout"]) or "").splitlines(), + fromfile="recorded", + tofile="now", + lineterm="", + ) + ) + if diff: + chunks.append(f"$ {' '.join(argv)}\n{diff}") + return "\n\n".join(chunks) + + +# --------------------------------------------------------------------------------------- +# parity -- cw's own gate. The ONLY function here that imports cw, and it does it lazily. +# --------------------------------------------------------------------------------------- + + +def parity(goldens_dir=None, *, out=None, verbose=False) -> int: + """Replay the committed argh-recorded goldens through cw. Exit code, not an exception. + + This is the definition of v1: it prints ``N shapes / M cases: identical`` and returns + ``0``. What it asserts, per case, with **every seam on its default** + (``convention=cw.ARGH``, ``decode=cw.argh_decode``, ``egress=cw.argh_egress``): exit + code, stdout, stderr, and the normalised ``usage:`` line, byte-identical to what argh + 0.31.3 produced. The ``--help`` bodies are recorded and not asserted (tier 3). + + It is deliberately **self-contained**. The goldens were recorded from real argh against + the seven hard-case *shapes* in :mod:`cw.tests.fixtures`, not against the seven fleet + repos -- so this needs stdlib plus cw, installs no fleet package, pulls no argh, and + runs on Windows. The seven real repos are gated separately, by :func:`replay` in each + repo's own CI, which is what a migration test is for. + + Args: + goldens_dir: Where the goldens are. ``None`` is the copy shipped inside ``cw``. + out: Where the report goes. ``None`` is :data:`sys.stdout`, resolved now. + verbose: Also print a line per shape that passed. + + Returns: + ``0`` when every case is identical, ``1`` otherwise. + """ + out = sys.stdout if out is None else out + goldens_dir = DFLT_GOLDENS_DIR if goldens_dir is None else goldens_dir + paths = sorted( + os.path.join(goldens_dir, name) + for name in os.listdir(goldens_dir) + if name.endswith(".json") + ) + if not paths: + out.write(f"no goldens in {goldens_dir}\n") + return 1 + + from cw.tests import fixtures # lazy on purpose: D4 keeps this file cw-free + + shapes, cases, failures = 0, 0, [] + with pinned_env(): + for path in paths: + golden = load_golden(path) + shape = fixtures.shape_named(golden["shape"]) + shapes += 1 + for recorded in golden["cases"]: + cases += 1 + fresh = _record( + lambda a: fixtures.cw_outcome(shape, a), recorded["argv"] + ) + verdict = _verdict(recorded, fresh, set()) + if verdict["status"] == "differs": + failures.append((shape.name, verdict)) + if verbose: + out.write(f" {shape.name}: {len(golden['cases'])} cases\n") + + for name, verdict in failures: + out.write(f"\n[{name}] {_report_line(verdict)}\n") + verdict_word = "identical" if not failures else f"{len(failures)} DIFFER" + out.write(f"{shapes} shapes / {cases} cases: {verdict_word}\n") + return 1 if failures else 0 + + +# --------------------------------------------------------------------------------------- +# python -m cw.testing +# --------------------------------------------------------------------------------------- + + +def _cli() -> argparse.ArgumentParser: + """``python -m cw.testing``'s parser, hand-built with argparse. + + Hand-built rather than built with :func:`cw.dispatch`, which would be the nicer dogfood, + because this module must not import ``cw`` (D4) -- and a CLI that quietly broke the one + property the module exists for would be an expensive joke. + """ + parser = argparse.ArgumentParser( + prog="python -m cw.testing", description=__doc__.split("\n\n")[0] + ) + subs = parser.add_subparsers(dest="command", metavar="command") + + gate = subs.add_parser("parity", help="run cw's committed-golden parity gate") + gate.add_argument("goldens_dir", nargs="?", default=None) + gate.add_argument("-v", "--verbose", action="store_true") + + record = subs.add_parser("characterize", help="record a console script's behaviour") + record.add_argument("prog", help="the command, e.g. 'python -m priv'") + record.add_argument("--cases", required=True, help="a cases file") + record.add_argument("-o", "--out-path", required=True) + record.add_argument("--note", default=None) + + again = subs.add_parser("replay", help="re-run a golden and diff") + again.add_argument("golden") + again.add_argument("--prog", default=None) + again.add_argument("--expect-diff", action="append", default=[]) + + advisory = subs.add_parser("diff-help", help="the advisory tier-3 --help diff") + advisory.add_argument("golden") + advisory.add_argument("--prog", default=None) + return parser + + +def main(argv=None) -> int: + """``python -m cw.testing``. Returns an exit code; the module raises it as SystemExit.""" + parser = _cli() + args = parser.parse_args(sys.argv[1:] if argv is None else list(argv)) + if args.command is None: + parser.print_usage() + return 0 + if args.command == "parity": + return parity(args.goldens_dir, verbose=args.verbose) + if args.command == "characterize": + golden = characterize( + args.prog, read_cases(args.cases), out_path=args.out_path, note=args.note + ) + print(f"recorded {len(golden['cases'])} cases to {args.out_path}") + return 0 + if args.command == "diff-help": + print(diff_help(args.golden, prog=args.prog) or "no --help changes") + return 0 + results = replay(args.golden, prog=args.prog, expect_diff=args.expect_diff) + bad = [r for r in results if r["status"] in ("differs", "unexpected-match")] + for result in bad: + print(_report_line(result)) + print(f"{len(results) - len(bad)}/{len(results)} identical") + return 1 if bad else 0 + + +if __name__ == "__main__": # pragma: no cover - exercised as a subprocess + sys.exit(main()) diff --git a/cw/tests/README.md b/cw/tests/README.md new file mode 100644 index 0000000..952b5c2 --- /dev/null +++ b/cw/tests/README.md @@ -0,0 +1,93 @@ +# The parity corpus + +``` +python -m cw.testing parity +``` + +> **v1 is done when that prints `8 shapes / 133 cases: identical` and exits 0.** + +`fixtures.py` holds eight self-contained shapes; `goldens/*.json` holds what **real argh +0.31.3** did with each of their argv vectors, recorded once and committed. `parity` replays +them through cw with every seam on its default (`convention=cw.ARGH`, `decode=cw.argh_decode`, +`egress=cw.argh_egress`) and asserts four things per case: **exit code, stdout, stderr, and +the normalised `usage:` line.** + +| shape | models | cases | +|---|---|---| +| `theremin` | `t/theremin` | 19 | +| `priv` | `t/priv` | 21 | +| `epythet` | `i/epythet` | 14 | +| `coact` | `t/coact` | 19 | +| `xa` | `t/xa` | 17 | +| `wads_pack` | `i/wads` | 17 | +| `lacing` | `t/lacing` | 9 | +| `contract` | the D2 contract's egress and error rows | 17 | +| **total** | | **133** | + +That total is **counted**, not asserted: +`tests/test_corpus_coverage.py::test_the_case_count_is_counted_rather_than_asserted` +recomputes it from `fixtures.SHAPES`. The canonical spec's "7 repos / 214 cases" was an +invented number with no file behind it; this one has 133 argv vectors in a file, each with a +comment saying which argh rule it pins. + +## Why shapes and not the seven repos + +As the spec first wrote it, parity "rebuilds each of the seven hard-case repos' command sets +through cw". It cannot run in cw's CI that way, for reasons that are structural: +`theremin/script_utils.py:12`, `coact/__main__.py:26` and `epythet/cli.py:15` are module-scope +`import argh`; `t/theremin` **depends on cw**, which is circular; and installing seven fleet +packages to test a package whose selling point is zero dependencies defeats the exercise. + +So the corpus reproduces the seven repos' *shapes*. The seven real repos are gated separately, +by `cw.testing.replay` in each repo's own CI at migration time — which is what D4 actually asks +for. + +## Contract-row coverage + +`tests/test_corpus_coverage.py` maps each of the 20 rows of the argh compatibility contract +(canonical spec §9) to what covers it, and fails if a row is covered by nothing. + +**17 of the 20 rows are asserted by `parity`.** The other three — 14 (`help` defaults to +`%(default)s`), 15 (help renders `repr(default)`, `None` → `-`) and 20 (a group's listing row +reads `group_kwargs['title']`, never `['help']`) — are observable **only** in a `--help` body, +and the golden format keeps help bodies at tier 3: recorded, diffed advisorily by `diff_help`, +never asserted. That is not laziness; `--help` wraps to `COLUMNS` and Python 3.13 reformatted +argparse's option column, so asserting it would produce false failures on a matrix cw has to +be green on. + +Those three are covered instead by `tests/argh_parity/`, which builds the same parser with argh +and with cw **in one process at one Python** and asserts the rendered help is byte-identical — +a comparison a committed golden cannot make. It needs `pip install -e '.[dev]'`; without argh +it skips itself. + +## Windows — ADR: option A, "normalise" + +Issue #14 asked whether parity means anything on the Windows runner (`test_on_windows = true`). +**Decision: A. Normalise, and run everywhere.** Recorded in `cw/testing.py`'s module docstring. + +Four things make it real rather than hopeful: + +1. **Newlines are normalised on both sides.** Goldens store LF and carry `"newlines": "lf"`; + every comparison runs both sides through `normalise_text`, so `\r\n` from a Windows + `print` asserts clean. Tested by `test_a_crlf_golden_asserts_clean_against_lf_output`. +2. **`parity` spawns no subprocess.** It runs in-process against these fixtures, so the + `.exe` console-script shim and console code pages never enter the picture. Only + `characterize`/`replay` spawn, and those pin `PYTHONUTF8`/`PYTHONIOENCODING`. +3. **`read_cases` parses a JSON list as well as a `shlex` line**, so a Windows user writing a + corpus never reaches POSIX-only `shlex`. +4. **Every golden is pure ASCII**, asserted by a test, so the code page cannot matter even in + principle. + +## Re-recording + +`argh` is **not** a dependency of cw — not at runtime and not in the `test` extra CI installs. +Recording is a one-time developer act: + +```bash +pip install -e '.[dev]' # this, and only this, brings argh 0.31.3 +python misc/record_goldens.py # all shapes, or name the ones you want +git diff cw/tests/goldens/ # review every line: this is the thing being asserted +``` + +Re-recording an unchanged fixture produces a byte-identical file (sorted keys, pinned env, +scrubbed heap addresses), so a non-empty `git diff` here always means a real behaviour change. diff --git a/cw/tests/__init__.py b/cw/tests/__init__.py new file mode 100644 index 0000000..cfa8aa6 --- /dev/null +++ b/cw/tests/__init__.py @@ -0,0 +1,7 @@ +"""Self-contained fixtures and committed argh goldens for :func:`cw.testing.parity`. + +Shipped **inside** the package on purpose: ``python -m cw.testing parity`` is the definition +of v1, and a gate that only runs from a source checkout is not a gate. Nothing here is +imported by ``cw`` itself -- :func:`cw.testing.parity` imports it lazily, and only when it +runs. +""" diff --git a/cw/tests/fixtures.py b/cw/tests/fixtures.py new file mode 100644 index 0000000..a60bd59 --- /dev/null +++ b/cw/tests/fixtures.py @@ -0,0 +1,1197 @@ +"""The parity corpus: the fleet's hard cases reproduced as *shapes*, not as repos. + +:func:`cw.testing.parity` is the definition of v1, and it must run in cw's own CI. As the +canonical spec first wrote it -- "for each of the seven hard-case repos ... rebuilds that +repo's command set through cw" -- it cannot, for reasons that are structural rather than +fixable: three of those repos ``import argh`` at module scope, one of them *depends on cw*, +and cw's whole selling point is that it installs nothing. A gate that needs seven fleet +packages checked out is a gate that is red on somebody else's commit. + +So the corpus reproduces the seven repos' **shapes**. Each one is a small, self-contained +module of plain functions that exercises the same grammar the real repo does, recorded once +against real argh 0.31.3 and committed. Parity then needs stdlib plus cw, installs no fleet +package, pulls no argh, and runs on Windows. The seven real repos are gated separately, by +:func:`cw.testing.replay` in each repo's own CI, which is what a migration test is for. + +Nothing here imports ``argh``: a shape carries an ``argh_build`` callable that *receives* +the ``argh`` module, so only the dev-only recorder (``misc/record_goldens.py``) ever needs +it installed. Nothing here is imported by ``cw`` either -- :func:`cw.testing.parity` imports +this module lazily, when it runs. + +Eight shapes: + +=============== =================== ===================================================== +shape models what it pins +=============== =================== ===================================================== +``theremin`` ``t/theremin`` ten overrides, ``nargs='?' const='list'``, the ``s`` + collision that makes ``--synth`` render long-first and + leaves ``--scale`` with no short at all +``priv`` ``t/priv`` a mapping whose keys name the commands, a + ``functools.partial`` with its bound keyword hidden, + and a group whose name stays verbatim +``epythet`` ``i/epythet`` ``list[str]`` inference, and the CI-critical + ``quickstart . --ignore`` -> ``[]`` +``coact`` ``t/coact`` three ``nargs`` shapes and ``*args`` +``xa`` ``t/xa`` non-identifier keys, two commands both called ``list`` + in different dicts, and a group +``wads_pack`` ``i/wads`` 34 parameters, ``**configs``, and ``verbose=True`` + becoming ``store_false`` +``lacing`` ``t/lacing`` a quoted annotation, PEP 563-blind under ``ARGH`` +``contract`` the D2 contract the egress and error rows, which no repo shape reaches +=============== =================== ===================================================== + +The ``rows`` field of each shape names which rows of the argh compatibility contract +(canonical spec section 9) it pins, and ``tests/test_corpus_coverage.py`` fails if a row is +covered by nothing. +""" + +import functools + +__all__ = [ + "SHAPES", + "Shape", + "case_count", + "cw_outcome", + "shape_named", + "use_command_error", +] + + +# ======================================================================================= +# The shape record +# ======================================================================================= + + +class Shape: + """One hard case: what cw is given, how argh was asked the same question, and the argv. + + ``argh_build`` takes the ``argh`` and ``argparse`` modules and returns the parser argh + builds. Taking them as arguments rather than importing them is what keeps this module + argh-free, and therefore shippable inside ``cw``. + """ + + def __init__( + self, + name, + *, + prog, + models, + pins, + rows, + cases, + cw_obj, + argh_build, + cw_kwargs=None, + cw_call=None, + ): + self.name = name + self.prog = prog + self.models = models + self.pins = pins + self.rows = frozenset(rows) + self.cases = tuple(tuple(case) for case in cases) + self.cw_obj = cw_obj + self.cw_kwargs = dict(cw_kwargs or {}, prog=prog) + self.argh_build = argh_build + #: ``(cw, argv, **kwargs) -> exit code``. The default is ``cw.dispatch``, the + #: idiom every migration in the spec uses. A shape overrides it only when it needs + #: a build-time keyword ``dispatch`` does not carry -- ``xa``'s ``group_kwargs`` + #: being the one case, which is why ``add_commands`` still exists. + self.cw_call = cw_call or _dispatch_call + + def __repr__(self): + return f"" + + +def _dispatch_call(cw, argv, **kwargs): + """The default cw side of a shape: ``cw.dispatch``, exactly as a migration writes it.""" + obj = kwargs.pop("obj") + return cw.dispatch(obj, list(argv), **kwargs) + + +def _xa_call(cw, argv, **kwargs): + """xa's cw side: ``mk_parser`` + ``add_commands`` + ``run``, for one keyword. + + ``group_kwargs`` is a build-time fact about *one group*, so it has no place in + ``dispatch``'s flat keyword space -- and ``add_commands`` is where argh put it too. + This is the one shape that needs it, and having it means the parity gate asserts + contract row 20 (a group's listing row reads ``title``, never ``help``) rather than + quietly dropping the only case that exercises it. + """ + obj = dict(kwargs.pop("obj")) + group = obj.pop("archive") + parser = cw.mk_parser(obj, **kwargs) + cw.add_commands( + parser, group, group_name="archive", group_kwargs=dict(XA_GROUP_KWARGS) + ) + return cw.run(parser, list(argv)) + + +def declare(argh, func, decls): + """Apply ``@argh.arg`` declarations to ``func``, idempotently. + + ``argh.arg`` mutates ``func`` in place -- it ``insert(0, ...)``s into a list it hangs + off the function -- so building the same parser twice would double every declaration. + Clearing first makes a rebuild mean what it says. ``reversed`` reproduces stacked + decorators, where the outermost is applied last and therefore ends up first. + + Only the dev-only recorder calls this; it takes ``argh`` as an argument for the same + reason the rest of this module does. + """ + from argh.constants import ATTR_ARGS + + if hasattr(func, ATTR_ARGS): + delattr(func, ATTR_ARGS) + for flags, kwargs in reversed(decls): + func = argh.arg(*flags, **kwargs)(func) + return func + + +def config_from(decls): + """The cw ``config`` leaf equivalent of a list of ``@argh.arg`` declarations. + + Written once, from one source, so the two spellings cannot drift. The difference + between them is exactly one rule: ``@argh.arg`` must be given the long flag, because + that is how argh guesses which parameter is meant; cw already knows the parameter, so + its ``flags`` key carries only what the inference did *not* produce, and the + append-merge does the rest. + + >>> config_from([(('-p', '--pipeline'), {'nargs': '?'}), (('--scale',), {})]) + {'pipeline': {'nargs': '?', 'flags': ['-p']}, 'scale': {}} + """ + config = {} + for flags, kwargs in decls: + param = _param_name_of(flags) + extra = [flag for flag in flags if flag != f"--{param.replace('_', '-')}"] + config[param] = dict(kwargs, flags=extra) if extra else dict(kwargs) + return config + + +def _param_name_of(flags): + """``('-i', '--ignore') -> 'ignore'`` -- argh's ``naive_guess_func_arg_name``. + + >>> _param_name_of(('-i', '--ignore')), _param_name_of(('project',)) + ('ignore', 'project') + """ + if len(flags) == 1: + return flags[0].lstrip("-").replace("-", "_") + for flag in flags: + if flag.startswith("--"): + return flag[2:].replace("-", "_") + raise ValueError(f"cannot guess a parameter name from {flags}") + + +def _single_command(argh, argparse, func, *, prog, decls=()): + """The parser argh builds for one command -- the ``set_default_command`` shape.""" + func = declare(argh, func, decls) if decls else func + parser = argparse.ArgumentParser( + prog=prog, formatter_class=argh.PARSER_FORMATTER, description=func.__doc__ + ) + argh.set_default_command(parser, func) + return parser + + +def _subcommands(argh, argparse, funcs, *, prog, description=None, groups=(), decls=()): + """The parser argh builds for a command set -- the ``add_commands`` shape.""" + for func, func_decls in decls: + declare(argh, func, func_decls) + parser = argparse.ArgumentParser( + prog=prog, formatter_class=argh.PARSER_FORMATTER, description=description + ) + argh.add_commands(parser, list(funcs)) + for group_name, group_funcs, group_kwargs in groups: + argh.add_commands( + parser, list(group_funcs), group_name=group_name, group_kwargs=group_kwargs + ) + return parser + + +# ======================================================================================= +# Shape 1 -- t/theremin. Ten overrides, five `nargs='?' const='list'`, the `s` collision. +# ======================================================================================= + +#: A bare flag means "list the options" -- theremin's real UX, and the reason five of its +#: ten declarations carry a `const`. +_LIST = dict(nargs="?", const="list") + +#: The ten declarations, in theremin's own order. `flags` is the *declared* flag list argh +#: needs; `config_from` derives what cw's config says from the same data. +THEREMIN_DECLS = [ + (("-p", "--pipeline"), dict(_LIST, help="Audio pipeline name")), + (("-v", "--video-features"), dict(_LIST, help="Video features function name")), + (("-k", "--knobs"), dict(_LIST, help="Audio knobs function name")), + # Declared long-first, which is what makes `--synth [SYNTH], -s [SYNTH]` render in that + # order: the inferred `-s` was suppressed by the collision with `scale`, so the + # declared `-s` is *appended* to the inferred `['--synth']` rather than leading it. + (("--synth", "-s"), dict(_LIST, help="Synthesizer function name")), + (("--log-video-features",), dict(help="Log hand features")), + (("--log-knobs",), dict(help="Log audio features")), + (("-r", "--record-to-file"), dict(help="Filename to save recording")), + (("-n", "--no-recording"), dict(help="Disable recording")), + (("-w", "--window-name"), dict(help="Window title")), + # No short flag is declared and none is inferred: `scale` collides with `synth` on `s`, + # and argh's collision pass suppresses the short for BOTH parameters. + (("--scale",), dict(_LIST, help="Scale for snapping; 'none' disables")), +] + + +def theremin_cli( + pipeline="theremin", + video_features="many_video_features", + knobs="theremin_knobs", + synth="theremin_synth", + log_video_features=False, + log_knobs=False, + record_to_file=None, + no_recording=False, + window_name="theremin", + scale=None, +): + """Run the theremin. + + Every parameter has a default, so every one becomes an option under + BY_NAME_IF_HAS_DEFAULT -- which is the policy argh's dispatch entry points default to + and the only one cw offers as ARGH. + """ + return ( + f"pipeline={pipeline!r} video_features={video_features!r} knobs={knobs!r} " + f"synth={synth!r} scale={scale!r} record_to_file={record_to_file!r} " + f"window_name={window_name!r} " + f"flags={log_video_features!r},{log_knobs!r},{no_recording!r}" + ) + + +THEREMIN = Shape( + "theremin", + prog="theremin", + models="t/theremin", + pins=( + "Ten @argh.arg overrides on a single command. The parameters `synth` and `scale` " + "both start with `s`, so NEITHER gets an inferred short flag; the declared " + "['--synth', '-s'] then appends `-s` for synth only. `--scale` ends with no short " + "at all. That falls out of the append-merge rule and is not special-cased." + ), + rows=(3, 4, 5, 8), + cw_obj=theremin_cli, + cw_kwargs={"config": config_from(THEREMIN_DECLS)}, + argh_build=lambda argh, argparse: _single_command( + argh, argparse, theremin_cli, prog="theremin", decls=THEREMIN_DECLS + ), + cases=[ + [], + ["--help"], + # `-p` with a value, and `-p` bare -- the const='list' sentinel. + ["-p", "flute"], + ["-p"], + ["--pipeline"], + ["--pipeline", "flute"], + # The whole point of the shape: `-s` reaches synth, long-first rendering and all. + ["-s", "saw"], + ["-s"], + ["--synth", "saw"], + # ... and `scale` has no short, so `-c`/`-a` are usage errors, not scale. + ["--scale"], + ["--scale", "minor"], + ["-a", "minor"], + # `-l` would be log_video_features' or log_knobs' short if the collision pass did + # not suppress both. It is a usage error, and that is the assertion. + ["-l"], + ["--log-knobs", "--log-video-features"], + ["-v", "hands"], + ["-k", "smooth"], + ["-r", "out.wav", "-n"], + ["-w", "Theremin"], + ["--nope"], + ], +) + + +# ======================================================================================= +# Shape 2 -- t/priv. A mapping of keys to callables, a partial, and a verbatim group. +# ======================================================================================= + + +def parse_pth_paths(path="."): + """Parse a .pth file into the package paths it names.""" + return f"parse_pth_paths({path!r})" + + +def align(*, repair=False): + """Report ecosystem drift, and optionally act on it.""" + return f"align(repair={repair!r})" + + +def render_pth(out="my_packages.pth"): + """Render the manifest from its committed source of truth.""" + return f"render_pth({out!r})" + + +def packages_from_config(pkg_dir=".", config_type="pyproject.toml"): + """List the packages a directory of projects declares.""" + return f"packages_from_config({pkg_dir!r}, {config_type!r})" + + +#: priv's real shape: a `functools.partial` in `__all__` that pre-binds `config_type`. argh +#: crashes on a partial outright, and priv's live workaround copies `__name__` off the base +#: function -- which is why `priv packages-from-all-setup-cfgs` does not exist today and +#: `-c/--config-type` is still exposed. cw takes the partial and the mapping key names it. +packages_from_all_setup_cfgs = functools.partial( + packages_from_config, config_type="setup.cfg" +) + + +def packages_from_all_setup_cfgs_for_argh(pkg_dir="."): + """List the packages a directory of projects declares.""" + return packages_from_all_setup_cfgs(pkg_dir) + + +# argh cannot be handed a partial, so the golden is recorded against the wrapper a repo has +# to hand-write today. The parity claim is that cw's partial + cw.HIDE produces the same +# command line as argh's hand-written wrapper -- which is the whole reason HIDE exists. +packages_from_all_setup_cfgs_for_argh.__name__ = "packages_from_all_setup_cfgs" + + +def git_branch_report(*, stale_days=30): + """Report branch drift across the fleet.""" + return f"git_branch_report(stale_days={stale_days!r})" + + +def git_land(branch, *, squash=True): + """Land a branch: review, PR, CI gate, squash-merge.""" + return f"git_land({branch!r}, squash={squash!r})" + + +PRIV_TOP = [parse_pth_paths, align, render_pth, packages_from_all_setup_cfgs_for_argh] +PRIV_GIT_OPS = [git_branch_report, git_land] + +PRIV = Shape( + "priv", + prog="priv", + models="t/priv", + pins=( + "A mapping whose keys name the commands, hyphenated (" + "`parse_pth_paths` -> `parse-pth-paths`); a group whose name stays VERBATIM " + "(`git_ops`, which is in priv's own README and is emitted as a user-facing " + "suggestion); and a functools.partial whose bound keyword is hidden with cw.HIDE " + "rather than re-exposed as a flag." + ), + rows=(1, 2, 11), + cw_obj={ + "parse_pth_paths": parse_pth_paths, + "align": align, + "render_pth": render_pth, + # The mapping KEY is the command name, which is what finally gives the partial the + # right one. Hyphenation still applies to the key, so this is `packages-from-...`. + "packages_from_all_setup_cfgs": packages_from_all_setup_cfgs, + "git_ops": {"git_branch_report": git_branch_report, "git_land": git_land}, + }, + cw_kwargs={ + # Don't re-expose the keyword the partial already bound. + "config": {"packages-from-all-setup-cfgs": {"config_type": "HIDE"}}, + "description": "Private fleet tooling.", + }, + argh_build=lambda argh, argparse: _subcommands( + argh, + argparse, + PRIV_TOP, + prog="priv", + description="Private fleet tooling.", + groups=[("git_ops", PRIV_GIT_OPS, None)], + ), + cases=[ + [], + ["--help"], + ["parse-pth-paths"], + # A defaulted POSITIONAL_OR_KEYWORD is an OPTION, so the bare word is rejected... + ["parse-pth-paths", "/somewhere"], + # ... and this is how you actually pass it. + ["parse-pth-paths", "--path", "/somewhere"], + # Hyphenation is not optional: the underscore spelling must NOT be a command. + ["parse_pth_paths"], + ["align"], + ["align", "--repair"], + ["render-pth"], + ["render-pth", "--out", "x.pth"], + ["packages-from-all-setup-cfgs"], + ["packages-from-all-setup-cfgs", "--pkg-dir", "/p"], + # The bound keyword is gone from the command line, in both spellings. + ["packages-from-all-setup-cfgs", "--config-type", "setup.cfg"], + # Group names stay verbatim -- `git_ops`, not `git-ops`. + ["git_ops"], + ["git-ops"], + ["git_ops", "--help"], + ["git_ops", "git-branch-report"], + ["git_ops", "git-branch-report", "--stale-days", "7"], + ["git_ops", "git-land", "build-v1"], + ["git_ops", "git-land", "build-v1", "--squash"], + ["nope"], + ], +) + + +# ======================================================================================= +# Shape 3 -- i/epythet. `list[str]` inference, and the case every fleet docs job runs. +# ======================================================================================= + + +def quickstart(project_dir, *, ignore: list = None, depth: int = None): + """Create a docsrc directory for a project. + + The annotation is a real `list` object, not a string, because this module has no + `from __future__ import annotations` -- which is exactly the condition under which + argh's hint guesser fires and the `@argh.arg('--ignore', nargs='*')` decorator can be + deleted outright. + """ + return f"quickstart({project_dir!r}, ignore={ignore!r}, depth={depth!r})" + + +def make_docsrc(project_dir, *, verbose=False): + """Generate the docsrc for a project.""" + return f"make_docsrc({project_dir!r}, verbose={verbose!r})" + + +def check_pages( + project_dir, *, ignore: list = None, depth: int = None, timeout: int = 30 +): + """Check that a project's GitHub Pages site is live. + + Row 12, and the pair that makes it observable: this function is identical to + `quickstart` in the two ways that matter, except that it carries one `@argh.arg`. That + one declaration turns hint inference OFF for the WHOLE function, so: + + * `--depth 2` arrives here as the STRING `'2'`, where `quickstart` gets the int `2` + (`depth`'s type comes only from its annotation -- its default is None, so the + default-value guesser has nothing to say about it); and + * `ignore` would lose its `nargs='*'` too, which is why the declaration has to put it + back explicitly. + + `timeout` is coerced either way: its default is `30`, and the default-value guesser + runs regardless of whether hints do. + """ + return ( + f"check_pages({project_dir!r}, ignore={ignore!r}, depth={depth!r}, " + f"timeout={timeout!r})" + ) + + +EPYTHET_DECLS = [(("--ignore",), dict(nargs="*"))] + +EPYTHET = Shape( + "epythet", + prog="epythet", + models="i/epythet", + pins=( + "A `list` annotation infers nargs='*', so epythet's decorator can be deleted. The " + "CI-critical case is `quickstart . --ignore` with ZERO values -- the literal line " + "in publish-github-pages/action.yml, running in every fleet repo's docs job -- " + "which must still parse to `ignore=[]`. Also row 12: the @argh.arg on " + "`check_pages` turns hint inference off for that whole function, so `timeout` " + "arrives as a string." + ), + rows=(1, 12, 13), + cw_obj=[quickstart, make_docsrc, check_pages], + cw_kwargs={ + "config": {"check-pages": config_from(EPYTHET_DECLS)}, + "description": "Setup and generate Sphinx docs effortlessly", + }, + argh_build=lambda argh, argparse: _subcommands( + argh, + argparse, + [quickstart, make_docsrc, check_pages], + prog="epythet", + description="Setup and generate Sphinx docs effortlessly", + decls=[(check_pages, EPYTHET_DECLS)], + ), + cases=[ + [], + ["--help"], + ["quickstart", "--help"], + ["quickstart", "."], + # The three lines the whole shape exists for. + ["quickstart", ".", "--ignore"], + ["quickstart", ".", "--ignore", "a", "b"], + ["quickstart", ".", "-i", "a"], + # No @argh.arg on quickstart, so hints fire: `depth` arrives as the int 2. + ["quickstart", ".", "--depth", "2"], + ["make-docsrc", ".", "--verbose"], + # One @argh.arg on check_pages, so hints do NOT fire: `depth` arrives as '2'. + ["check-pages", ".", "--depth", "2"], + # ... but `timeout` is coerced anyway, from its default, not its annotation. + ["check-pages", ".", "--timeout", "5"], + ["check-pages", ".", "--ignore"], + ["check-pages", ".", "--ignore", "x"], + ["quickstart"], + ], +) + + +# ======================================================================================= +# Shape 4 -- t/coact. Three nargs shapes and *args. +# ======================================================================================= + + +def inventory(project): + """List what a project already has.""" + return f"inventory({project!r})" + + +def scaffold(agents): + """Wire agent files into a project.""" + return f"scaffold({agents!r})" + + +def publish(source): + """Publish tool refs, a skill directory, or both.""" + return f"publish({source!r})" + + +def estimate(*agents: str): + """Estimate the cost of running some agents. + + Row 10: VAR_POSITIONAL becomes a positional with nargs='*' and needs no declaration at + all. The ingress re-expands it, so the function really does receive three arguments. + """ + return f"estimate{agents!r}" + + +def emit(*, tags=[]): + """Emit a report. + + Row 7: a `list` DEFAULT infers nargs='*' with no annotation involved. (A `tuple` + default does too; a `tuple` ANNOTATION infers nothing, which is why row 7 says + "default".) + """ + return f"emit(tags={tags!r})" + + +def back(step, *, mode="undo"): + """Step a project back. + + Row 9: `choices` makes argh take `type` from `choices[0]`, so `--depth 2` arrives as an + int here and the choices are checked against the coerced value. + """ + return f"back({step!r}, mode={mode!r})" + + +COACT_DECLS = { + "inventory": [(("project",), dict(nargs="?", default="."))], + "scaffold": [(("agents",), dict(nargs="+"))], + "publish": [(("source",), dict(nargs="+"))], + "back": [(("--mode",), dict(choices=["undo", "redo"]))], + # A DECLARED nargs of None is falsy, and argh's merge takes `nargs` only when it is + # truthy -- so this declaration does NOT unset the inferred `nargs='*'`. There is no + # spelling that does (ADR-0003's third open question is answered "there isn't one"), + # and an implementation that merged with plain assignment would break `estimate a b c`. + "estimate": [(("agents",), dict(nargs=None, help="Agent names"))], +} + +COACT = Shape( + "coact", + prog="coact", + models="t/coact", + pins=( + "The three nargs shapes coact declares -- nargs='?' with a default, and two " + "nargs='+' -- plus a VAR_POSITIONAL that needs no declaration, a list DEFAULT that " + "infers nargs='*' with no annotation, and a `choices` that supplies `type`." + ), + rows=(1, 7, 9, 10), + cw_obj=[inventory, scaffold, publish, estimate, emit, back], + cw_kwargs={ + "config": {name: config_from(d) for name, d in COACT_DECLS.items()}, + "description": "Agent project scaffolding.", + }, + argh_build=lambda argh, argparse: _subcommands( + argh, + argparse, + [inventory, scaffold, publish, estimate, emit, back], + prog="coact", + description="Agent project scaffolding.", + decls=[ + (inventory, COACT_DECLS["inventory"]), + (scaffold, COACT_DECLS["scaffold"]), + (publish, COACT_DECLS["publish"]), + (back, COACT_DECLS["back"]), + (estimate, COACT_DECLS["estimate"]), + ], + ), + cases=[ + [], + ["--help"], + ["inventory"], + ["inventory", "."], + ["inventory", "/elsewhere"], + ["inventory", "a", "b"], + ["scaffold"], + ["scaffold", "one.md"], + ["scaffold", "one.md", "two.md"], + ["publish", "mod:func", "skills/"], + ["estimate"], + ["estimate", "a"], + ["estimate", "a", "b", "c"], + ["emit"], + ["emit", "--tags"], + ["emit", "--tags", "x", "y"], + ["back", "1"], + ["back", "1", "--mode", "redo"], + ["back", "1", "--mode", "sideways"], + ], +) + + +# ======================================================================================= +# Shape 5 -- t/xa. Keys name the commands; two of them are called `list`. +# ======================================================================================= + + +def list_cmd(*, running=False): + """List sessions.""" + return f"list_cmd(running={running!r})" + + +def spawn_cmd(name, *, detach=False, host="local"): + """Spawn a session. + + Row 4's other half: `host` starts with `h`, and `-h` already belongs to `--help`. argh + strips it, so `host` gets no short flag even though no other parameter collides with it. + An implementation that forgot would not merely add a flag -- argparse would refuse to + build the parser at all ("conflicting option string: -h"), so this is asserted by every + case in the shape at once. + """ + return f"spawn_cmd({name!r}, detach={detach!r}, host={host!r})" + + +def gen_secret_cmd(*, length=32): + """Generate a shared secret.""" + return f"gen_secret_cmd(length={length!r})" + + +def archive_list_cmd(*, pattern="*"): + """List archived sessions.""" + return f"archive_list_cmd(pattern={pattern!r})" + + +def archive_log_cmd(session): + """Show an archived session's log.""" + return f"archive_log_cmd({session!r})" + + +#: xa's live workaround: thirteen `__name__` mutations, because argh reads `__name__` and +#: has no way to be told a command's name. Two of them assign the SAME string (`list`) to +#: different functions, which is exactly why a mapping is the right shape. +def _mutated(func, name): + """Give ``func`` the name xa assigns it today. Recorded so cw's keys can be diffed.""" + func.__name__ = name + return func + + +#: `group_kwargs={'help': ...}` -- what xa passes today. Per spec section 9.1 argh reads +#: `title` for the listing row, so this string displays NOTHING. cw reproduces that: it +#: passes group_kwargs whole to add_subparsers and `help=group_kwargs.get('title')` to +#: add_parser, even when that is None (omitting it makes the group row vanish entirely). +XA_GROUP_KWARGS = {"help": "Postmortem archive (list, log, forensics)."} + +XA = Shape( + "xa", + prog="xa", + models="t/xa", + pins=( + "A mapping key names the command verbatim-then-hyphenated, so the non-identifier " + "key 'gen-secret' works and needs no __name__ mutation. Two commands are both " + "called `list` in different dicts, which a list of functions cannot express. And " + "the group's `group_kwargs={'help': ...}` displays nothing -- argh reads `title` " + "for that row -- so translating it would be an improvement, and therefore a break." + ), + # Row 20 is exercised here but NOT claimed: whether the listing row reads `title` or + # `help` shows only in a --help body, which the golden format keeps at tier 3. It is + # asserted by tests/argh_parity/test_cli_parity.py instead. Row 4 is claimed because + # `host` proves `-h` is stripped, and that IS observable at tier 1. + rows=(1, 4), + cw_obj={ + "list": list_cmd, + "spawn": spawn_cmd, + "gen-secret": gen_secret_cmd, + "archive": {"list": archive_list_cmd, "log": archive_log_cmd}, + }, + cw_kwargs={"description": "Session multiplexer."}, + cw_call=lambda cw, argv, **kwargs: _xa_call(cw, argv, **kwargs), + argh_build=lambda argh, argparse: _subcommands( + argh, + argparse, + [ + _mutated(list_cmd, "list"), + _mutated(spawn_cmd, "spawn"), + _mutated(gen_secret_cmd, "gen-secret"), + ], + prog="xa", + description="Session multiplexer.", + groups=[ + ( + "archive", + [_mutated(archive_list_cmd, "list"), _mutated(archive_log_cmd, "log")], + XA_GROUP_KWARGS, + ) + ], + ), + cases=[ + [], + ["--help"], + ["list"], + ["list", "--running"], + ["spawn", "s1"], + ["spawn", "s1", "--detach"], + # `-h` is --help's, not host's, even though nothing else starts with `h`. + ["spawn", "s1", "-h"], + ["spawn", "s1", "--host", "remote"], + # A non-identifier key is a fine command name and needs no __name__ mutation. + ["gen-secret"], + ["gen-secret", "--length", "8"], + ["gen_secret"], + ["archive"], + ["archive", "--help"], + # The second `list` -- a different function, same word, different dict. + ["archive", "list"], + ["archive", "list", "--pattern", "x*"], + ["archive", "log", "s1"], + ["archive", "nope"], + ], +) + + +# ======================================================================================= +# Shape 6 -- i/wads pack. 34 parameters, `**configs`, and the store_false footgun. +# ======================================================================================= + + +def populate_pkg_dir( + pkg_dir, + *, + version=None, + description=None, + root_url=None, + author=None, + author_email=None, + license="mit", + keywords=None, + url=None, + project_urls=None, + classifiers=None, + python_requires=None, + install_requires=None, + extras_require=None, + entry_points=None, + package_data=None, + include_package_data=False, + zip_safe=False, + long_description=None, + long_description_content_type=None, + platforms=None, + provides=None, + obsoletes=None, + download_url=None, + maintainer=None, + maintainer_email=None, + docsrc=None, + display_name=None, + dry_run=False, + overwrite=False, + verbose=True, + **configs, +): + """Populate a package directory from a template. + + Thirty-four parameters, which is what makes the short-flag collision pass observable as + data rather than as a heuristic: with this many first characters almost everything + collides and almost nothing gets a short flag. `verbose: bool = True` becomes + `store_false`, so `--verbose` turns verbosity OFF -- the footgun D2 preserves on + purpose. And `**configs` contributes no command-line arguments at all. + """ + return ( + f"populate_pkg_dir({pkg_dir!r}) verbose={verbose!r} license={license!r} " + f"version={version!r} dry_run={dry_run!r} configs={configs!r}" + ) + + +def go(pkg_dir, *, version=None, verbose=True): + """Package and publish, in one step.""" + return f"go({pkg_dir!r}, version={version!r}, verbose={verbose!r})" + + +WADS_PACK = Shape( + "wads_pack", + prog="pack", + models="i/wads", + pins=( + "Collision suppression at scale: 34 parameters, so almost no short flag survives. " + "`verbose: bool = True` -> store_false, so `--verbose` turns verbosity OFF. " + "`**configs` contributes no CLI arguments, and the leftovers argparse never " + "produced are collected on ingress anyway." + ), + rows=(1, 3, 4, 6, 8, 11), + cw_obj=[populate_pkg_dir, go], + cw_kwargs={"description": "Utils to package and publish."}, + argh_build=lambda argh, argparse: _subcommands( + argh, + argparse, + [populate_pkg_dir, go], + prog="pack", + description="Utils to package and publish.", + ), + cases=[ + [], + ["--help"], + ["populate-pkg-dir", "--help"], + ["populate-pkg-dir", "/p"], + # The footgun, asserted rather than fixed: this turns verbosity OFF. + ["populate-pkg-dir", "/p", "--verbose"], + ["go", "/p", "--verbose"], + ["go", "/p"], + ["go", "/p", "--version", "0.0.1"], + # `d` is shared by description/docsrc/display_name/download_url/dry_run, `v` by + # version/verbose. Almost nothing keeps a short flag, and `-d`/`-v` are errors. + ["populate-pkg-dir", "/p", "-d", "x"], + ["populate-pkg-dir", "/p", "-v"], + # `l` is shared by license/long_description/long_description_content_type, so even + # `--license` -- which looks like it should own `-l` -- has no short flag. + ["populate-pkg-dir", "/p", "-l", "apache"], + ["populate-pkg-dir", "/p", "--license", "apache"], + # Exactly five first characters are unique across the 33 options, so exactly five + # short flags survive: -r -k -u -c -z. The collision pass is data, not a heuristic. + ["populate-pkg-dir", "/p", "-k", "cli"], + ["populate-pkg-dir", "/p", "-z"], + ["populate-pkg-dir", "/p", "-c", "Topic"], + # `**configs` produces no flags, so this is a usage error, not a config key. + ["populate-pkg-dir", "/p", "--anything-at-all", "1"], + ["populate-pkg-dir", "/p", "--dry-run", "--overwrite"], + ], +) + + +# ======================================================================================= +# Shape 7 -- t/lacing. A quoted annotation, invisible to argh. +# ======================================================================================= + + +def migrate(path: str, *, to_version: "int | None" = None): + """Upgrade PATH to the current store schema, in place. + + Row 13: argh reads `__annotations__` raw, so this annotation is the STRING + `'int | None'`, which is not in its if-chain. The guesser returns nothing, the default + is None so the default-value path yields nothing either, and `to_version` arrives as a + `str`. That is why lacing's own body says "argh delivers option values as strings; + coerce here" -- and why the coercion may not be deleted until MODERN is opted into. + """ + return f"migrate({path!r}, to_version={to_version!r})" + + +def convert(source, target, *, fmt: str = "annot"): + """Convert an annotation file between formats.""" + return f"convert({source!r}, {target!r}, fmt={fmt!r})" + + +def list_formats(): + """List the annotation formats lacing understands.""" + return ["annot", "csv", "json"] + + +LACING = Shape( + "lacing", + prog="lacing", + models="t/lacing", + pins=( + "A quoted annotation -- `to_version: 'int | None'` -- is read raw and matches " + "nothing, so the value arrives as a str and `repr` shows the quotes. This is live " + "in ~32 fleet files. Under cw.MODERN it would resolve to int; under cw.ARGH, which " + "is the default and what parity asserts, it must NOT." + ), + rows=(1, 13, 16), + cw_obj=[migrate, convert, list_formats], + cw_kwargs={"description": "Annotation store tooling."}, + argh_build=lambda argh, argparse: _subcommands( + argh, + argparse, + [migrate, convert, list_formats], + prog="lacing", + description="Annotation store tooling.", + ), + cases=[ + [], + ["--help"], + ["migrate", "a.annot"], + # The assertion: `'3'`, with quotes. An int would mean cw resolved the hint. + ["migrate", "a.annot", "--to-version", "3"], + ["migrate", "a.annot", "-t", "3"], + ["convert", "a.annot", "b.csv"], + ["convert", "a.annot", "b.csv", "--fmt", "csv"], + # A list return iterates, one line each -- row 16, the egress whitelist. + ["list-formats"], + ["migrate"], + ], +) + + +# ======================================================================================= +# Shape 8 -- the contract rows no repo shape reaches: egress and errors. +# ======================================================================================= + + +def returns_none(): + """Row 17: None prints NOTHING -- not 'None', not a blank line.""" + return None + + +def returns_zero(): + """Row 17: 0 DOES print, which is why the check cannot be `if result:`.""" + return 0 + + +def returns_false(): + """Row 17: False DOES print.""" + return False + + +def returns_empty_string(): + """Row 17: '' DOES print -- as a blank line.""" + return "" + + +def returns_list(): + """Row 16: a list iterates, one line per item.""" + return ["alpha", "beta"] + + +def returns_tuple(): + """Row 16: a tuple iterates too.""" + return ("alpha", "beta") + + +def returns_dict(): + """Row 16: a dict is NOT iterated. It prints its repr, on one line.""" + return {"a": 1, "b": 2} + + +def returns_set(): + """Row 16: a set is not in the whitelist either, so it prints its repr. + + Set repr ordering is stable here only because the set has one element -- which is the + point: the corpus may not depend on hash ordering. + """ + return {"only"} + + +def returns_generator(): + """Row 16: a generator IS in the whitelist, and iterates.""" + return (str(n) for n in range(3)) + + +def returns_iterator(): + """Row 16, the trap: `iter([...])` is a `list_iterator`, NOT a GeneratorType. + + The whitelist is three concrete types, so this prints a repr, not two lines. Any fleet + command returning `map(...)`, `filter(...)` or a comprehension-fed iterator is printing + a repr today. + """ + return iter(["alpha", "beta"]) + + +def streams_then_fails(): + """Row 18: a generator streams LAZILY, so what it yielded lands before the error.""" + + def gen(): + yield "first" + yield "second" + raise RuntimeError("halfway") + + return gen() + + +#: Which library's ``CommandError`` the contract shape raises. Row 19 is about what a +#: framework does with *its own* expected-failure exception, so comparing cw's handling of +#: ``cw.CommandError`` against argh's handling of ``cw.CommandError`` would compare nothing: +#: argh does not recognise it and reports it as a crash. Both sides install their own. +_COMMAND_ERROR = [] + + +def use_command_error(cls) -> None: + """Install the ``CommandError`` class the contract shape raises. + + :func:`cw_outcome` installs :class:`cw.CommandError`; the dev-only recorder installs + ``argh.CommandError``. Neither side gets a default, because a default here would be a + silent way to record the wrong thing. + """ + _COMMAND_ERROR[:] = [cls] + + +def _command_error(message, code=None): + """Raise the installed ``CommandError``, with a code when one is asked for.""" + if not _COMMAND_ERROR: + raise RuntimeError( + "no CommandError class installed: call fixtures.use_command_error(cls) first. " + "cw.tests.fixtures.cw_outcome does this for you." + ) + cls = _COMMAND_ERROR[0] + raise cls(message) if code is None else cls(message, code=code) + + +def raises_command_error(): + """Row 19: `CommandError` is one line on stderr and a non-zero exit, no traceback.""" + _command_error("no such pipeline") + + +def raises_command_error_coded(): + """Row 19: and it carries its own exit code, which is NOT 1.""" + _command_error("gone", code=7) + + +def raises_system_exit_code(): + """Row 19: a SystemExit keeps its exit code.""" + raise SystemExit(3) + + +def raises_system_exit_message(): + """Row 19: a SystemExit with a string prints it and exits 1.""" + raise SystemExit("bye") + + +CONTRACT_COMMANDS = [ + returns_none, + returns_zero, + returns_false, + returns_empty_string, + returns_list, + returns_tuple, + returns_dict, + returns_set, + returns_generator, + returns_iterator, + streams_then_fails, + raises_command_error, + raises_command_error_coded, + raises_system_exit_code, + raises_system_exit_message, +] + +CONTRACT = Shape( + "contract", + prog="contract", + models="the D2 contract itself", + pins=( + "The egress and error rows, which every repo shape has in it but none exercises " + "on purpose: the three-concrete-type whitelist (a dict prints on ONE line and a " + "plain iterator prints a repr), None printing nothing while 0/False/'' print, " + "lazy generator streaming, and CommandError's one-line stderr and exit code." + ), + rows=(16, 17, 18, 19), + cw_obj=CONTRACT_COMMANDS, + cw_kwargs={"description": "The D2 contract rows, one command each."}, + argh_build=lambda argh, argparse: _subcommands( + argh, + argparse, + CONTRACT_COMMANDS, + prog="contract", + description="The D2 contract rows, one command each.", + ), + cases=[ + [], + ["--help"], + ["returns-none"], + ["returns-zero"], + ["returns-false"], + ["returns-empty-string"], + ["returns-list"], + ["returns-tuple"], + ["returns-dict"], + ["returns-set"], + ["returns-generator"], + ["returns-iterator"], + ["streams-then-fails"], + ["raises-command-error"], + ["raises-command-error-coded"], + ["raises-system-exit-code"], + ["raises-system-exit-message"], + ], +) + + +# ======================================================================================= +# The registry, and the one function parity calls +# ======================================================================================= + +#: Every shape, by name. :func:`cw.testing.parity` looks a golden's ``shape`` up here. +SHAPES = { + shape.name: shape + for shape in ( + CONTRACT, + COACT, + EPYTHET, + LACING, + PRIV, + THEREMIN, + WADS_PACK, + XA, + ) +} + + +def shape_named(name) -> Shape: + """The shape called ``name``, or an error that names the real ones. + + >>> shape_named('theremin').models + 't/theremin' + >>> shape_named('nope') + Traceback (most recent call last): + ... + KeyError: "no fixture shape named 'nope'. There are: contract, coact, epythet, lacing, priv, theremin, wads_pack, xa" + """ + try: + return SHAPES[name] + except KeyError: + raise KeyError( + f"no fixture shape named {name!r}. There are: {', '.join(SHAPES)}" + ) from None + + +def case_count() -> int: + """How many argv vectors the corpus holds, counted rather than asserted. + + >>> case_count() > 100 + True + """ + return sum(len(shape.cases) for shape in SHAPES.values()) + + +def _resolved_config(shape): + """``shape.cw_kwargs['config']`` with the ``'HIDE'`` placeholder made real. + + The shapes are plain data and must not import ``cw`` at module scope -- that is what + lets this file ship inside the package without making ``import cw`` circular. So a + config leaf that means :data:`cw.HIDE` is written as the string ``'HIDE'`` and resolved + here, where ``cw`` is already imported. + """ + from cw.base import HIDE + + config = shape.cw_kwargs.get("config") + if not config: + return config + return { + key: { + param: (HIDE if value == "HIDE" else value) for param, value in leaf.items() + } + for key, leaf in config.items() + } + + +def cw_outcome(shape, argv) -> dict: + """Run one case through cw, with every seam on its default, and report the outcome. + + This is the cw half of the parity gate. The argh half lives in the dev-only recorder, + and the two share :func:`cw.testing.capture` so the comparison is between two runs of + the same measuring instrument. + """ + import cw + from cw.testing import capture + + use_command_error(cw.CommandError) + kwargs = dict(shape.cw_kwargs, obj=shape.cw_obj) + if "config" in kwargs: + kwargs["config"] = _resolved_config(shape) + return capture(lambda: shape.cw_call(cw, list(argv), **kwargs)) diff --git a/cw/tests/goldens/coact.json b/cw/tests/goldens/coact.json new file mode 100644 index 0000000..56b8ecd --- /dev/null +++ b/cw/tests/goldens/coact.json @@ -0,0 +1,244 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "usage: coact [-h] {inventory,scaffold,publish,estimate,emit,back} ...\n", + "tier": 1, + "usage": "usage: coact [-h] {inventory,scaffold,publish,estimate,emit,back} ..." + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: coact [-h] {inventory,scaffold,publish,estimate,emit,back} ...\n\nAgent project scaffolding.\n\npositional arguments:\n {inventory,scaffold,publish,estimate,emit,back}\n inventory List what a project already has.\n scaffold Wire agent files into a project.\n publish Publish tool refs, a skill directory, or both.\n estimate Estimate the cost of running some agents. Row 10: VAR_POSITIONAL becomes a\n positional with nargs='*' and needs no declaration at all. The ingress re-\n expands it, so the function really does receive three arguments.\n emit Emit a report. Row 7: a `list` DEFAULT infers nargs='*' with no annotation\n involved. (A `tuple` default does too; a `tuple` ANNOTATION infers\n nothing, which is why row 7 says \"default\".)\n back Step a project back. Row 9: `choices` makes argh take `type` from\n `choices[0]`, so `--depth 2` arrives as an int here and the choices are\n checked against the coerced value.\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: coact [-h] {inventory,scaffold,publish,estimate,emit,back} ..." + }, + { + "argv": [ + "inventory" + ], + "returncode": 0, + "stderr": "", + "stdout": "inventory('.')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "inventory", + "." + ], + "returncode": 0, + "stderr": "", + "stdout": "inventory('.')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "inventory", + "/elsewhere" + ], + "returncode": 0, + "stderr": "", + "stdout": "inventory('/elsewhere')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "inventory", + "a", + "b" + ], + "returncode": 2, + "stderr": "usage: coact [-h] {inventory,scaffold,publish,estimate,emit,back} ...\ncoact: error: unrecognized arguments: b\n", + "stdout": "", + "tier": 1, + "usage": "usage: coact [-h] {inventory,scaffold,publish,estimate,emit,back} ... coact: error: unrecognized arguments: b" + }, + { + "argv": [ + "scaffold" + ], + "returncode": 2, + "stderr": "usage: coact scaffold [-h] agents [agents ...]\ncoact scaffold: error: the following arguments are required: agents\n", + "stdout": "", + "tier": 1, + "usage": "usage: coact scaffold [-h] agents [agents ...] coact scaffold: error: the following arguments are required: agents" + }, + { + "argv": [ + "scaffold", + "one.md" + ], + "returncode": 0, + "stderr": "", + "stdout": "scaffold(['one.md'])\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "scaffold", + "one.md", + "two.md" + ], + "returncode": 0, + "stderr": "", + "stdout": "scaffold(['one.md', 'two.md'])\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "publish", + "mod:func", + "skills/" + ], + "returncode": 0, + "stderr": "", + "stdout": "publish(['mod:func', 'skills/'])\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "estimate" + ], + "returncode": 0, + "stderr": "", + "stdout": "estimate()\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "estimate", + "a" + ], + "returncode": 0, + "stderr": "", + "stdout": "estimate('a',)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "estimate", + "a", + "b", + "c" + ], + "returncode": 0, + "stderr": "", + "stdout": "estimate('a', 'b', 'c')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "emit" + ], + "returncode": 0, + "stderr": "", + "stdout": "emit(tags=[])\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "emit", + "--tags" + ], + "returncode": 0, + "stderr": "", + "stdout": "emit(tags=[])\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "emit", + "--tags", + "x", + "y" + ], + "returncode": 0, + "stderr": "", + "stdout": "emit(tags=['x', 'y'])\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "back", + "1" + ], + "returncode": 0, + "stderr": "", + "stdout": "back('1', mode='undo')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "back", + "1", + "--mode", + "redo" + ], + "returncode": 0, + "stderr": "", + "stdout": "back('1', mode='redo')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "back", + "1", + "--mode", + "sideways" + ], + "returncode": 2, + "stderr": "usage: coact back [-h] [-m {undo,redo}] step\ncoact back: error: argument -m/--mode: invalid choice: 'sideways' (choose from undo, redo)\n", + "stdout": "", + "tier": 1, + "usage": "usage: coact back [-h] [-m {undo,redo}] step coact back: error: argument -m/--mode: invalid choice: 'sideways' (choose from undo, redo)" + } + ], + "cw_golden": 1, + "env": { + "COLUMNS": "100", + "NO_COLOR": "1", + "PYTHONHASHSEED": "0", + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + "TERM": "dumb" + }, + "models": "t/coact", + "newlines": "lf", + "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", + "pins": "The three nargs shapes coact declares -- nargs='?' with a default, and two nargs='+' -- plus a VAR_POSITIONAL that needs no declaration, a list DEFAULT that infers nargs='*' with no annotation, and a `choices` that supplies `type`.", + "prog": [ + "coact" + ], + "recorded_with": { + "python": "3.12.12", + "tool": "argh", + "version": "0.31.3" + }, + "rows": [ + 1, + 7, + 9, + 10 + ], + "shape": "coact" +} diff --git a/cw/tests/goldens/contract.json b/cw/tests/goldens/contract.json new file mode 100644 index 0000000..eeb2a6a --- /dev/null +++ b/cw/tests/goldens/contract.json @@ -0,0 +1,200 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "usage: contract [-h]\n {returns-none,returns-zero,returns-false,returns-empty-string,returns-list,returns-tuple,returns-dict,returns-set,returns-generator,returns-iterator,streams-then-fails,raises-command-error,raises-command-error-coded,raises-system-exit-code,raises-system-exit-message}\n ...\n", + "tier": 1, + "usage": "usage: contract [-h] {returns-none,returns-zero,returns-false,returns-empty-string,returns-list,returns-tuple,returns-dict,returns-set,returns-generator,returns-iterator,streams-then-fails,raises-command-error,raises-command-error-coded,raises-system-exit-code,raises-system-exit-message} ..." + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: contract [-h]\n {returns-none,returns-zero,returns-false,returns-empty-string,returns-list,returns-tuple,returns-dict,returns-set,returns-generator,returns-iterator,streams-then-fails,raises-command-error,raises-command-error-coded,raises-system-exit-code,raises-system-exit-message}\n ...\n\nThe D2 contract rows, one command each.\n\npositional arguments:\n {returns-none,returns-zero,returns-false,returns-empty-string,returns-list,returns-tuple,returns-dict,returns-set,returns-generator,returns-iterator,streams-then-fails,raises-command-error,raises-command-error-coded,raises-system-exit-code,raises-system-exit-message}\n returns-none Row 17: None prints NOTHING -- not 'None', not a blank line.\n returns-zero Row 17: 0 DOES print, which is why the check cannot be `if result:`.\n returns-false Row 17: False DOES print.\n returns-empty-string\n Row 17: '' DOES print -- as a blank line.\n returns-list Row 16: a list iterates, one line per item.\n returns-tuple Row 16: a tuple iterates too.\n returns-dict Row 16: a dict is NOT iterated. It prints its repr, on one line.\n returns-set Row 16: a set is not in the whitelist either, so it prints its repr. Set\n repr ordering is stable here only because the set has one element -- which\n is the point: the corpus may not depend on hash ordering.\n returns-generator Row 16: a generator IS in the whitelist, and iterates.\n returns-iterator Row 16, the trap: `iter([...])` is a `list_iterator`, NOT a GeneratorType.\n The whitelist is three concrete types, so this prints a repr, not two\n lines. Any fleet command returning `map(...)`, `filter(...)` or a\n comprehension-fed iterator is printing a repr today.\n streams-then-fails Row 18: a generator streams LAZILY, so what it yielded lands before the\n error.\n raises-command-error\n Row 19: `CommandError` is one line on stderr and a non-zero exit, no\n traceback.\n raises-command-error-coded\n Row 19: and it carries its own exit code, which is NOT 1.\n raises-system-exit-code\n Row 19: a SystemExit keeps its exit code.\n raises-system-exit-message\n Row 19: a SystemExit with a string prints it and exits 1.\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: contract [-h] {returns-none,returns-zero,returns-false,returns-empty-string,returns-list,returns-tuple,returns-dict,returns-set,returns-generator,returns-iterator,streams-then-fails,raises-command-error,raises-command-error-coded,raises-system-exit-code,raises-system-exit-message} ..." + }, + { + "argv": [ + "returns-none" + ], + "returncode": 0, + "stderr": "", + "stdout": "", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-zero" + ], + "returncode": 0, + "stderr": "", + "stdout": "0\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-false" + ], + "returncode": 0, + "stderr": "", + "stdout": "False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-empty-string" + ], + "returncode": 0, + "stderr": "", + "stdout": "\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-list" + ], + "returncode": 0, + "stderr": "", + "stdout": "alpha\nbeta\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-tuple" + ], + "returncode": 0, + "stderr": "", + "stdout": "alpha\nbeta\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-dict" + ], + "returncode": 0, + "stderr": "", + "stdout": "{'a': 1, 'b': 2}\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-set" + ], + "returncode": 0, + "stderr": "", + "stdout": "{'only'}\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-generator" + ], + "returncode": 0, + "stderr": "", + "stdout": "0\n1\n2\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "returns-iterator" + ], + "returncode": 0, + "stderr": "", + "stdout": "\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "streams-then-fails" + ], + "returncode": 1, + "stderr": "RuntimeError: halfway\n", + "stdout": "first\nsecond\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "raises-command-error" + ], + "returncode": 1, + "stderr": "CommandError: no such pipeline\n", + "stdout": "", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "raises-command-error-coded" + ], + "returncode": 7, + "stderr": "CommandError: gone\n", + "stdout": "", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "raises-system-exit-code" + ], + "returncode": 3, + "stderr": "", + "stdout": "", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "raises-system-exit-message" + ], + "returncode": 1, + "stderr": "bye\n", + "stdout": "", + "tier": 1, + "usage": "" + } + ], + "cw_golden": 1, + "env": { + "COLUMNS": "100", + "NO_COLOR": "1", + "PYTHONHASHSEED": "0", + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + "TERM": "dumb" + }, + "models": "the D2 contract itself", + "newlines": "lf", + "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", + "pins": "The egress and error rows, which every repo shape has in it but none exercises on purpose: the three-concrete-type whitelist (a dict prints on ONE line and a plain iterator prints a repr), None printing nothing while 0/False/'' print, lazy generator streaming, and CommandError's one-line stderr and exit code.", + "prog": [ + "contract" + ], + "recorded_with": { + "python": "3.12.12", + "tool": "argh", + "version": "0.31.3" + }, + "rows": [ + 16, + 17, + 18, + 19 + ], + "shape": "contract" +} diff --git a/cw/tests/goldens/epythet.json b/cw/tests/goldens/epythet.json new file mode 100644 index 0000000..b3f2a23 --- /dev/null +++ b/cw/tests/goldens/epythet.json @@ -0,0 +1,196 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "usage: epythet [-h] {quickstart,make-docsrc,check-pages} ...\n", + "tier": 1, + "usage": "usage: epythet [-h] {quickstart,make-docsrc,check-pages} ..." + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: epythet [-h] {quickstart,make-docsrc,check-pages} ...\n\nSetup and generate Sphinx docs effortlessly\n\npositional arguments:\n {quickstart,make-docsrc,check-pages}\n quickstart Create a docsrc directory for a project. The annotation is a real `list`\n object, not a string, because this module has no `from __future__ import\n annotations` -- which is exactly the condition under which argh's hint\n guesser fires and the `@argh.arg('--ignore', nargs='*')` decorator can be\n deleted outright.\n make-docsrc Generate the docsrc for a project.\n check-pages Check that a project's GitHub Pages site is live. Row 12, and the pair\n that makes it observable: this function is identical to `quickstart` in\n the two ways that matter, except that it carries one `@argh.arg`. That one\n declaration turns hint inference OFF for the WHOLE function, so: *\n `--depth 2` arrives here as the STRING `'2'`, where `quickstart` gets the\n int `2` (`depth`'s type comes only from its annotation -- its default is\n None, so the default-value guesser has nothing to say about it); and *\n `ignore` would lose its `nargs='*'` too, which is why the declaration has\n to put it back explicitly. `timeout` is coerced either way: its default is\n `30`, and the default-value guesser runs regardless of whether hints do.\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: epythet [-h] {quickstart,make-docsrc,check-pages} ..." + }, + { + "argv": [ + "quickstart", + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: epythet quickstart [-h] [-i [IGNORE ...]] [-d DEPTH] project-dir\n\nCreate a docsrc directory for a project.\n\nThe annotation is a real `list` object, not a string, because this module has no\n`from __future__ import annotations` -- which is exactly the condition under which\nargh's hint guesser fires and the `@argh.arg('--ignore', nargs='*')` decorator can be\ndeleted outright.\n\npositional arguments:\n project-dir -\n\noptions:\n -h, --help show this help message and exit\n -i [IGNORE ...], --ignore [IGNORE ...]\n -\n -d DEPTH, --depth DEPTH\n -\n", + "tier": 3, + "usage": "usage: epythet quickstart [-h] [-i [IGNORE ...]] [-d DEPTH] project-dir" + }, + { + "argv": [ + "quickstart", + "." + ], + "returncode": 0, + "stderr": "", + "stdout": "quickstart('.', ignore=None, depth=None)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "quickstart", + ".", + "--ignore" + ], + "returncode": 0, + "stderr": "", + "stdout": "quickstart('.', ignore=[], depth=None)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "quickstart", + ".", + "--ignore", + "a", + "b" + ], + "returncode": 0, + "stderr": "", + "stdout": "quickstart('.', ignore=['a', 'b'], depth=None)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "quickstart", + ".", + "-i", + "a" + ], + "returncode": 0, + "stderr": "", + "stdout": "quickstart('.', ignore=['a'], depth=None)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "quickstart", + ".", + "--depth", + "2" + ], + "returncode": 0, + "stderr": "", + "stdout": "quickstart('.', ignore=None, depth=2)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "make-docsrc", + ".", + "--verbose" + ], + "returncode": 0, + "stderr": "", + "stdout": "make_docsrc('.', verbose=True)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "check-pages", + ".", + "--depth", + "2" + ], + "returncode": 0, + "stderr": "", + "stdout": "check_pages('.', ignore=None, depth='2', timeout=30)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "check-pages", + ".", + "--timeout", + "5" + ], + "returncode": 0, + "stderr": "", + "stdout": "check_pages('.', ignore=None, depth=None, timeout=5)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "check-pages", + ".", + "--ignore" + ], + "returncode": 0, + "stderr": "", + "stdout": "check_pages('.', ignore=[], depth=None, timeout=30)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "check-pages", + ".", + "--ignore", + "x" + ], + "returncode": 0, + "stderr": "", + "stdout": "check_pages('.', ignore=['x'], depth=None, timeout=30)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "quickstart" + ], + "returncode": 2, + "stderr": "usage: epythet quickstart [-h] [-i [IGNORE ...]] [-d DEPTH] project-dir\nepythet quickstart: error: the following arguments are required: project-dir\n", + "stdout": "", + "tier": 1, + "usage": "usage: epythet quickstart [-h] [-i [IGNORE ...]] [-d DEPTH] project-dir epythet quickstart: error: the following arguments are required: project-dir" + } + ], + "cw_golden": 1, + "env": { + "COLUMNS": "100", + "NO_COLOR": "1", + "PYTHONHASHSEED": "0", + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + "TERM": "dumb" + }, + "models": "i/epythet", + "newlines": "lf", + "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", + "pins": "A `list` annotation infers nargs='*', so epythet's decorator can be deleted. The CI-critical case is `quickstart . --ignore` with ZERO values -- the literal line in publish-github-pages/action.yml, running in every fleet repo's docs job -- which must still parse to `ignore=[]`. Also row 12: the @argh.arg on `check_pages` turns hint inference off for that whole function, so `timeout` arrives as a string.", + "prog": [ + "epythet" + ], + "recorded_with": { + "python": "3.12.12", + "tool": "argh", + "version": "0.31.3" + }, + "rows": [ + 1, + 12, + 13 + ], + "shape": "epythet" +} diff --git a/cw/tests/goldens/lacing.json b/cw/tests/goldens/lacing.json new file mode 100644 index 0000000..70cdb75 --- /dev/null +++ b/cw/tests/goldens/lacing.json @@ -0,0 +1,132 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "usage: lacing [-h] {migrate,convert,list-formats} ...\n", + "tier": 1, + "usage": "usage: lacing [-h] {migrate,convert,list-formats} ..." + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: lacing [-h] {migrate,convert,list-formats} ...\n\nAnnotation store tooling.\n\npositional arguments:\n {migrate,convert,list-formats}\n migrate Upgrade PATH to the current store schema, in place. Row 13: argh reads\n `__annotations__` raw, so this annotation is the STRING `'int | None'`,\n which is not in its if-chain. The guesser returns nothing, the default is\n None so the default-value path yields nothing either, and `to_version`\n arrives as a `str`. That is why lacing's own body says \"argh delivers\n option values as strings; coerce here\" -- and why the coercion may not be\n deleted until MODERN is opted into.\n convert Convert an annotation file between formats.\n list-formats List the annotation formats lacing understands.\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: lacing [-h] {migrate,convert,list-formats} ..." + }, + { + "argv": [ + "migrate", + "a.annot" + ], + "returncode": 0, + "stderr": "", + "stdout": "migrate('a.annot', to_version=None)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "migrate", + "a.annot", + "--to-version", + "3" + ], + "returncode": 0, + "stderr": "", + "stdout": "migrate('a.annot', to_version='3')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "migrate", + "a.annot", + "-t", + "3" + ], + "returncode": 0, + "stderr": "", + "stdout": "migrate('a.annot', to_version='3')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "convert", + "a.annot", + "b.csv" + ], + "returncode": 0, + "stderr": "", + "stdout": "convert('a.annot', 'b.csv', fmt='annot')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "convert", + "a.annot", + "b.csv", + "--fmt", + "csv" + ], + "returncode": 0, + "stderr": "", + "stdout": "convert('a.annot', 'b.csv', fmt='csv')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "list-formats" + ], + "returncode": 0, + "stderr": "", + "stdout": "annot\ncsv\njson\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "migrate" + ], + "returncode": 2, + "stderr": "usage: lacing migrate [-h] [-t TO_VERSION] path\nlacing migrate: error: the following arguments are required: path\n", + "stdout": "", + "tier": 1, + "usage": "usage: lacing migrate [-h] [-t TO_VERSION] path lacing migrate: error: the following arguments are required: path" + } + ], + "cw_golden": 1, + "env": { + "COLUMNS": "100", + "NO_COLOR": "1", + "PYTHONHASHSEED": "0", + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + "TERM": "dumb" + }, + "models": "t/lacing", + "newlines": "lf", + "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", + "pins": "A quoted annotation -- `to_version: 'int | None'` -- is read raw and matches nothing, so the value arrives as a str and `repr` shows the quotes. This is live in ~32 fleet files. Under cw.MODERN it would resolve to int; under cw.ARGH, which is the default and what parity asserts, it must NOT.", + "prog": [ + "lacing" + ], + "recorded_with": { + "python": "3.12.12", + "tool": "argh", + "version": "0.31.3" + }, + "rows": [ + 1, + 13, + 16 + ], + "shape": "lacing" +} diff --git a/cw/tests/goldens/priv.json b/cw/tests/goldens/priv.json new file mode 100644 index 0000000..5b0f32c --- /dev/null +++ b/cw/tests/goldens/priv.json @@ -0,0 +1,259 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ...\n", + "tier": 1, + "usage": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ..." + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ...\n\nPrivate fleet tooling.\n\npositional arguments:\n {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops}\n parse-pth-paths Parse a .pth file into the package paths it names.\n align Report ecosystem drift, and optionally act on it.\n render-pth Render the manifest from its committed source of truth.\n packages-from-all-setup-cfgs\n List the packages a directory of projects declares.\n git_ops\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ..." + }, + { + "argv": [ + "parse-pth-paths" + ], + "returncode": 0, + "stderr": "", + "stdout": "parse_pth_paths('.')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "parse-pth-paths", + "/somewhere" + ], + "returncode": 2, + "stderr": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ...\npriv: error: unrecognized arguments: /somewhere\n", + "stdout": "", + "tier": 1, + "usage": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ... priv: error: unrecognized arguments: /somewhere" + }, + { + "argv": [ + "parse-pth-paths", + "--path", + "/somewhere" + ], + "returncode": 0, + "stderr": "", + "stdout": "parse_pth_paths('/somewhere')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "parse_pth_paths" + ], + "returncode": 2, + "stderr": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ...\npriv: error: argument {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops}: invalid choice: 'parse_pth_paths' (choose from parse-pth-paths, align, render-pth, packages-from-all-setup-cfgs, git_ops)\n", + "stdout": "", + "tier": 1, + "usage": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ... priv: error: argument {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops}: invalid choice: 'parse_pth_paths' (choose from parse-pth-paths, align, render-pth, packages-from-all-setup-cfgs, git_ops)" + }, + { + "argv": [ + "align" + ], + "returncode": 0, + "stderr": "", + "stdout": "align(repair=False)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "align", + "--repair" + ], + "returncode": 0, + "stderr": "", + "stdout": "align(repair=True)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "render-pth" + ], + "returncode": 0, + "stderr": "", + "stdout": "render_pth('my_packages.pth')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "render-pth", + "--out", + "x.pth" + ], + "returncode": 0, + "stderr": "", + "stdout": "render_pth('x.pth')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "packages-from-all-setup-cfgs" + ], + "returncode": 0, + "stderr": "", + "stdout": "packages_from_config('.', 'setup.cfg')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "packages-from-all-setup-cfgs", + "--pkg-dir", + "/p" + ], + "returncode": 0, + "stderr": "", + "stdout": "packages_from_config('/p', 'setup.cfg')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "packages-from-all-setup-cfgs", + "--config-type", + "setup.cfg" + ], + "returncode": 2, + "stderr": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ...\npriv: error: unrecognized arguments: --config-type setup.cfg\n", + "stdout": "", + "tier": 1, + "usage": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ... priv: error: unrecognized arguments: --config-type setup.cfg" + }, + { + "argv": [ + "git_ops" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ...\n", + "tier": 1, + "usage": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ..." + }, + { + "argv": [ + "git-ops" + ], + "returncode": 2, + "stderr": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ...\npriv: error: argument {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops}: invalid choice: 'git-ops' (choose from parse-pth-paths, align, render-pth, packages-from-all-setup-cfgs, git_ops)\n", + "stdout": "", + "tier": 1, + "usage": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ... priv: error: argument {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops}: invalid choice: 'git-ops' (choose from parse-pth-paths, align, render-pth, packages-from-all-setup-cfgs, git_ops)" + }, + { + "argv": [ + "git_ops", + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: priv git_ops [-h] {git-branch-report,git-land} ...\n\npositional arguments:\n {git-branch-report,git-land}\n git-branch-report Report branch drift across the fleet.\n git-land Land a branch: review, PR, CI gate, squash-merge.\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: priv git_ops [-h] {git-branch-report,git-land} ..." + }, + { + "argv": [ + "git_ops", + "git-branch-report" + ], + "returncode": 0, + "stderr": "", + "stdout": "git_branch_report(stale_days=30)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "git_ops", + "git-branch-report", + "--stale-days", + "7" + ], + "returncode": 0, + "stderr": "", + "stdout": "git_branch_report(stale_days=7)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "git_ops", + "git-land", + "build-v1" + ], + "returncode": 0, + "stderr": "", + "stdout": "git_land('build-v1', squash=True)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "git_ops", + "git-land", + "build-v1", + "--squash" + ], + "returncode": 0, + "stderr": "", + "stdout": "git_land('build-v1', squash=False)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "nope" + ], + "returncode": 2, + "stderr": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ...\npriv: error: argument {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops}: invalid choice: 'nope' (choose from parse-pth-paths, align, render-pth, packages-from-all-setup-cfgs, git_ops)\n", + "stdout": "", + "tier": 1, + "usage": "usage: priv [-h] {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops} ... priv: error: argument {parse-pth-paths,align,render-pth,packages-from-all-setup-cfgs,git_ops}: invalid choice: 'nope' (choose from parse-pth-paths, align, render-pth, packages-from-all-setup-cfgs, git_ops)" + } + ], + "cw_golden": 1, + "env": { + "COLUMNS": "100", + "NO_COLOR": "1", + "PYTHONHASHSEED": "0", + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + "TERM": "dumb" + }, + "models": "t/priv", + "newlines": "lf", + "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", + "pins": "A mapping whose keys name the commands, hyphenated (`parse_pth_paths` -> `parse-pth-paths`); a group whose name stays VERBATIM (`git_ops`, which is in priv's own README and is emitted as a user-facing suggestion); and a functools.partial whose bound keyword is hidden with cw.HIDE rather than re-exposed as a flag.", + "prog": [ + "priv" + ], + "recorded_with": { + "python": "3.12.12", + "tool": "argh", + "version": "0.31.3" + }, + "rows": [ + 1, + 2, + 11 + ], + "shape": "priv" +} diff --git a/cw/tests/goldens/theremin.json b/cw/tests/goldens/theremin.json new file mode 100644 index 0000000..5fb4fae --- /dev/null +++ b/cw/tests/goldens/theremin.json @@ -0,0 +1,232 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: theremin [-h] [-p [PIPELINE]] [-v [VIDEO_FEATURES]] [-k [KNOBS]] [--synth [SYNTH]]\n [--log-video-features] [--log-knobs] [-r RECORD_TO_FILE] [-n] [-w WINDOW_NAME]\n [--scale [SCALE]]\n\nRun the theremin.\n\n Every parameter has a default, so every one becomes an option under\n BY_NAME_IF_HAS_DEFAULT -- which is the policy argh's dispatch entry points default to\n and the only one cw offers as ARGH.\n \n\noptions:\n -h, --help show this help message and exit\n -p [PIPELINE], --pipeline [PIPELINE]\n Audio pipeline name (default: 'theremin')\n -v [VIDEO_FEATURES], --video-features [VIDEO_FEATURES]\n Video features function name (default: 'many_video_features')\n -k [KNOBS], --knobs [KNOBS]\n Audio knobs function name (default: 'theremin_knobs')\n --synth [SYNTH], -s [SYNTH]\n Synthesizer function name (default: 'theremin_synth')\n --log-video-features Log hand features (default: False)\n --log-knobs Log audio features (default: False)\n -r RECORD_TO_FILE, --record-to-file RECORD_TO_FILE\n Filename to save recording (default: -)\n -n, --no-recording Disable recording (default: False)\n -w WINDOW_NAME, --window-name WINDOW_NAME\n Window title (default: 'theremin')\n --scale [SCALE] Scale for snapping; 'none' disables (default: -)\n", + "tier": 3, + "usage": "usage: theremin [-h] [-p [PIPELINE]] [-v [VIDEO_FEATURES]] [-k [KNOBS]] [--synth [SYNTH]] [--log-video-features] [--log-knobs] [-r RECORD_TO_FILE] [-n] [-w WINDOW_NAME] [--scale [SCALE]]" + }, + { + "argv": [ + "-p", + "flute" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='flute' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "-p" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='list' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "--pipeline" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='list' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "--pipeline", + "flute" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='flute' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "-s", + "saw" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='saw' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "-s" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='list' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "--synth", + "saw" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='saw' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "--scale" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale='list' record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "--scale", + "minor" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale='minor' record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "-a", + "minor" + ], + "returncode": 2, + "stderr": "usage: theremin [-h] [-p [PIPELINE]] [-v [VIDEO_FEATURES]] [-k [KNOBS]] [--synth [SYNTH]]\n [--log-video-features] [--log-knobs] [-r RECORD_TO_FILE] [-n] [-w WINDOW_NAME]\n [--scale [SCALE]]\ntheremin: error: unrecognized arguments: -a minor\n", + "stdout": "", + "tier": 1, + "usage": "usage: theremin [-h] [-p [PIPELINE]] [-v [VIDEO_FEATURES]] [-k [KNOBS]] [--synth [SYNTH]] [--log-video-features] [--log-knobs] [-r RECORD_TO_FILE] [-n] [-w WINDOW_NAME] [--scale [SCALE]] theremin: error: unrecognized arguments: -a minor" + }, + { + "argv": [ + "-l" + ], + "returncode": 2, + "stderr": "usage: theremin [-h] [-p [PIPELINE]] [-v [VIDEO_FEATURES]] [-k [KNOBS]] [--synth [SYNTH]]\n [--log-video-features] [--log-knobs] [-r RECORD_TO_FILE] [-n] [-w WINDOW_NAME]\n [--scale [SCALE]]\ntheremin: error: unrecognized arguments: -l\n", + "stdout": "", + "tier": 1, + "usage": "usage: theremin [-h] [-p [PIPELINE]] [-v [VIDEO_FEATURES]] [-k [KNOBS]] [--synth [SYNTH]] [--log-video-features] [--log-knobs] [-r RECORD_TO_FILE] [-n] [-w WINDOW_NAME] [--scale [SCALE]] theremin: error: unrecognized arguments: -l" + }, + { + "argv": [ + "--log-knobs", + "--log-video-features" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file=None window_name='theremin' flags=True,True,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "-v", + "hands" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='hands' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "-k", + "smooth" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='smooth' synth='theremin_synth' scale=None record_to_file=None window_name='theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "-r", + "out.wav", + "-n" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file='out.wav' window_name='theremin' flags=False,False,True\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "-w", + "Theremin" + ], + "returncode": 0, + "stderr": "", + "stdout": "pipeline='theremin' video_features='many_video_features' knobs='theremin_knobs' synth='theremin_synth' scale=None record_to_file=None window_name='Theremin' flags=False,False,False\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "--nope" + ], + "returncode": 2, + "stderr": "usage: theremin [-h] [-p [PIPELINE]] [-v [VIDEO_FEATURES]] [-k [KNOBS]] [--synth [SYNTH]]\n [--log-video-features] [--log-knobs] [-r RECORD_TO_FILE] [-n] [-w WINDOW_NAME]\n [--scale [SCALE]]\ntheremin: error: unrecognized arguments: --nope\n", + "stdout": "", + "tier": 1, + "usage": "usage: theremin [-h] [-p [PIPELINE]] [-v [VIDEO_FEATURES]] [-k [KNOBS]] [--synth [SYNTH]] [--log-video-features] [--log-knobs] [-r RECORD_TO_FILE] [-n] [-w WINDOW_NAME] [--scale [SCALE]] theremin: error: unrecognized arguments: --nope" + } + ], + "cw_golden": 1, + "env": { + "COLUMNS": "100", + "NO_COLOR": "1", + "PYTHONHASHSEED": "0", + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + "TERM": "dumb" + }, + "models": "t/theremin", + "newlines": "lf", + "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", + "pins": "Ten @argh.arg overrides on a single command. The parameters `synth` and `scale` both start with `s`, so NEITHER gets an inferred short flag; the declared ['--synth', '-s'] then appends `-s` for synth only. `--scale` ends with no short at all. That falls out of the append-merge rule and is not special-cased.", + "prog": [ + "theremin" + ], + "recorded_with": { + "python": "3.12.12", + "tool": "argh", + "version": "0.31.3" + }, + "rows": [ + 3, + 4, + 5, + 8 + ], + "shape": "theremin" +} diff --git a/cw/tests/goldens/wads_pack.json b/cw/tests/goldens/wads_pack.json new file mode 100644 index 0000000..3d96d03 --- /dev/null +++ b/cw/tests/goldens/wads_pack.json @@ -0,0 +1,237 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "usage: pack [-h] {populate-pkg-dir,go} ...\n", + "tier": 1, + "usage": "usage: pack [-h] {populate-pkg-dir,go} ..." + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: pack [-h] {populate-pkg-dir,go} ...\n\nUtils to package and publish.\n\npositional arguments:\n {populate-pkg-dir,go}\n populate-pkg-dir Populate a package directory from a template. Thirty-four parameters,\n which is what makes the short-flag collision pass observable as data\n rather than as a heuristic: with this many first characters almost\n everything collides and almost nothing gets a short flag. `verbose: bool =\n True` becomes `store_false`, so `--verbose` turns verbosity OFF -- the\n footgun D2 preserves on purpose. And `**configs` contributes no command-\n line arguments at all.\n go Package and publish, in one step.\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: pack [-h] {populate-pkg-dir,go} ..." + }, + { + "argv": [ + "populate-pkg-dir", + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: pack populate-pkg-dir [-h] [--version VERSION] [--description DESCRIPTION] [-r ROOT_URL]\n [--author AUTHOR] [--author-email AUTHOR_EMAIL] [--license LICENSE]\n [-k KEYWORDS] [-u URL] [--project-urls PROJECT_URLS] [-c CLASSIFIERS]\n [--python-requires PYTHON_REQUIRES]\n [--install-requires INSTALL_REQUIRES]\n [--extras-require EXTRAS_REQUIRE] [--entry-points ENTRY_POINTS]\n [--package-data PACKAGE_DATA] [--include-package-data] [-z]\n [--long-description LONG_DESCRIPTION]\n [--long-description-content-type LONG_DESCRIPTION_CONTENT_TYPE]\n [--platforms PLATFORMS] [--provides PROVIDES] [--obsoletes OBSOLETES]\n [--download-url DOWNLOAD_URL] [--maintainer MAINTAINER]\n [--maintainer-email MAINTAINER_EMAIL] [--docsrc DOCSRC]\n [--display-name DISPLAY_NAME] [--dry-run] [--overwrite] [--verbose]\n pkg-dir\n\nPopulate a package directory from a template.\n\nThirty-four parameters, which is what makes the short-flag collision pass observable as\ndata rather than as a heuristic: with this many first characters almost everything\ncollides and almost nothing gets a short flag. `verbose: bool = True` becomes\n`store_false`, so `--verbose` turns verbosity OFF -- the footgun D2 preserves on\npurpose. And `**configs` contributes no command-line arguments at all.\n\npositional arguments:\n pkg-dir -\n\noptions:\n -h, --help show this help message and exit\n --version VERSION -\n --description DESCRIPTION\n -\n -r ROOT_URL, --root-url ROOT_URL\n -\n --author AUTHOR -\n --author-email AUTHOR_EMAIL\n -\n --license LICENSE 'mit'\n -k KEYWORDS, --keywords KEYWORDS\n -\n -u URL, --url URL -\n --project-urls PROJECT_URLS\n -\n -c CLASSIFIERS, --classifiers CLASSIFIERS\n -\n --python-requires PYTHON_REQUIRES\n -\n --install-requires INSTALL_REQUIRES\n -\n --extras-require EXTRAS_REQUIRE\n -\n --entry-points ENTRY_POINTS\n -\n --package-data PACKAGE_DATA\n -\n --include-package-data\n False\n -z, --zip-safe False\n --long-description LONG_DESCRIPTION\n -\n --long-description-content-type LONG_DESCRIPTION_CONTENT_TYPE\n -\n --platforms PLATFORMS\n -\n --provides PROVIDES -\n --obsoletes OBSOLETES\n -\n --download-url DOWNLOAD_URL\n -\n --maintainer MAINTAINER\n -\n --maintainer-email MAINTAINER_EMAIL\n -\n --docsrc DOCSRC -\n --display-name DISPLAY_NAME\n -\n --dry-run False\n --overwrite False\n --verbose True\n", + "tier": 3, + "usage": "usage: pack populate-pkg-dir [-h] [--version VERSION] [--description DESCRIPTION] [-r ROOT_URL] [--author AUTHOR] [--author-email AUTHOR_EMAIL] [--license LICENSE] [-k KEYWORDS] [-u URL] [--project-urls PROJECT_URLS] [-c CLASSIFIERS] [--python-requires PYTHON_REQUIRES] [--install-requires INSTALL_REQUIRES] [--extras-require EXTRAS_REQUIRE] [--entry-points ENTRY_POINTS] [--package-data PACKAGE_DATA] [--include-package-data] [-z] [--long-description LONG_DESCRIPTION] [--long-description-content-type LONG_DESCRIPTION_CONTENT_TYPE] [--platforms PLATFORMS] [--provides PROVIDES] [--obsoletes OBSOLETES] [--download-url DOWNLOAD_URL] [--maintainer MAINTAINER] [--maintainer-email MAINTAINER_EMAIL] [--docsrc DOCSRC] [--display-name DISPLAY_NAME] [--dry-run] [--overwrite] [--verbose] pkg-dir" + }, + { + "argv": [ + "populate-pkg-dir", + "/p" + ], + "returncode": 0, + "stderr": "", + "stdout": "populate_pkg_dir('/p') verbose=True license='mit' version=None dry_run=False configs={}\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "--verbose" + ], + "returncode": 0, + "stderr": "", + "stdout": "populate_pkg_dir('/p') verbose=False license='mit' version=None dry_run=False configs={}\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "go", + "/p", + "--verbose" + ], + "returncode": 0, + "stderr": "", + "stdout": "go('/p', version=None, verbose=False)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "go", + "/p" + ], + "returncode": 0, + "stderr": "", + "stdout": "go('/p', version=None, verbose=True)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "go", + "/p", + "--version", + "0.0.1" + ], + "returncode": 0, + "stderr": "", + "stdout": "go('/p', version='0.0.1', verbose=True)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "-d", + "x" + ], + "returncode": 2, + "stderr": "usage: pack [-h] {populate-pkg-dir,go} ...\npack: error: unrecognized arguments: -d x\n", + "stdout": "", + "tier": 1, + "usage": "usage: pack [-h] {populate-pkg-dir,go} ... pack: error: unrecognized arguments: -d x" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "-v" + ], + "returncode": 2, + "stderr": "usage: pack [-h] {populate-pkg-dir,go} ...\npack: error: unrecognized arguments: -v\n", + "stdout": "", + "tier": 1, + "usage": "usage: pack [-h] {populate-pkg-dir,go} ... pack: error: unrecognized arguments: -v" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "-l", + "apache" + ], + "returncode": 2, + "stderr": "usage: pack [-h] {populate-pkg-dir,go} ...\npack: error: unrecognized arguments: -l apache\n", + "stdout": "", + "tier": 1, + "usage": "usage: pack [-h] {populate-pkg-dir,go} ... pack: error: unrecognized arguments: -l apache" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "--license", + "apache" + ], + "returncode": 0, + "stderr": "", + "stdout": "populate_pkg_dir('/p') verbose=True license='apache' version=None dry_run=False configs={}\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "-k", + "cli" + ], + "returncode": 0, + "stderr": "", + "stdout": "populate_pkg_dir('/p') verbose=True license='mit' version=None dry_run=False configs={}\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "-z" + ], + "returncode": 0, + "stderr": "", + "stdout": "populate_pkg_dir('/p') verbose=True license='mit' version=None dry_run=False configs={}\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "-c", + "Topic" + ], + "returncode": 0, + "stderr": "", + "stdout": "populate_pkg_dir('/p') verbose=True license='mit' version=None dry_run=False configs={}\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "--anything-at-all", + "1" + ], + "returncode": 2, + "stderr": "usage: pack [-h] {populate-pkg-dir,go} ...\npack: error: unrecognized arguments: --anything-at-all 1\n", + "stdout": "", + "tier": 1, + "usage": "usage: pack [-h] {populate-pkg-dir,go} ... pack: error: unrecognized arguments: --anything-at-all 1" + }, + { + "argv": [ + "populate-pkg-dir", + "/p", + "--dry-run", + "--overwrite" + ], + "returncode": 0, + "stderr": "", + "stdout": "populate_pkg_dir('/p') verbose=True license='mit' version=None dry_run=True configs={}\n", + "tier": 1, + "usage": "" + } + ], + "cw_golden": 1, + "env": { + "COLUMNS": "100", + "NO_COLOR": "1", + "PYTHONHASHSEED": "0", + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + "TERM": "dumb" + }, + "models": "i/wads", + "newlines": "lf", + "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", + "pins": "Collision suppression at scale: 34 parameters, so almost no short flag survives. `verbose: bool = True` -> store_false, so `--verbose` turns verbosity OFF. `**configs` contributes no CLI arguments, and the leftovers argparse never produced are collected on ingress anyway.", + "prog": [ + "pack" + ], + "recorded_with": { + "python": "3.12.12", + "tool": "argh", + "version": "0.31.3" + }, + "rows": [ + 1, + 3, + 4, + 6, + 8, + 11 + ], + "shape": "wads_pack" +} diff --git a/cw/tests/goldens/xa.json b/cw/tests/goldens/xa.json new file mode 100644 index 0000000..76d6cbe --- /dev/null +++ b/cw/tests/goldens/xa.json @@ -0,0 +1,217 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "usage: xa [-h] {list,spawn,gen-secret,archive} ...\n", + "tier": 1, + "usage": "usage: xa [-h] {list,spawn,gen-secret,archive} ..." + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: xa [-h] {list,spawn,gen-secret,archive} ...\n\nSession multiplexer.\n\npositional arguments:\n {list,spawn,gen-secret,archive}\n list List sessions.\n spawn Spawn a session. Row 4's other half: `host` starts with `h`, and `-h`\n already belongs to `--help`. argh strips it, so `host` gets no short flag\n even though no other parameter collides with it. An implementation that\n forgot would not merely add a flag -- argparse would refuse to build the\n parser at all (\"conflicting option string: -h\"), so this is asserted by\n every case in the shape at once.\n gen-secret Generate a shared secret.\n archive\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: xa [-h] {list,spawn,gen-secret,archive} ..." + }, + { + "argv": [ + "list" + ], + "returncode": 0, + "stderr": "", + "stdout": "list_cmd(running=False)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "list", + "--running" + ], + "returncode": 0, + "stderr": "", + "stdout": "list_cmd(running=True)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "spawn", + "s1" + ], + "returncode": 0, + "stderr": "", + "stdout": "spawn_cmd('s1', detach=False, host='local')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "spawn", + "s1", + "--detach" + ], + "returncode": 0, + "stderr": "", + "stdout": "spawn_cmd('s1', detach=True, host='local')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "spawn", + "s1", + "-h" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: xa spawn [-h] [-d] [--host HOST] name\n\nSpawn a session.\n\nRow 4's other half: `host` starts with `h`, and `-h` already belongs to `--help`. argh\nstrips it, so `host` gets no short flag even though no other parameter collides with it.\nAn implementation that forgot would not merely add a flag -- argparse would refuse to\nbuild the parser at all (\"conflicting option string: -h\"), so this is asserted by every\ncase in the shape at once.\n\npositional arguments:\n name -\n\noptions:\n -h, --help show this help message and exit\n -d, --detach False\n --host HOST 'local'\n", + "tier": 3, + "usage": "usage: xa spawn [-h] [-d] [--host HOST] name" + }, + { + "argv": [ + "spawn", + "s1", + "--host", + "remote" + ], + "returncode": 0, + "stderr": "", + "stdout": "spawn_cmd('s1', detach=False, host='remote')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "gen-secret" + ], + "returncode": 0, + "stderr": "", + "stdout": "gen_secret_cmd(length=32)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "gen-secret", + "--length", + "8" + ], + "returncode": 0, + "stderr": "", + "stdout": "gen_secret_cmd(length=8)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "gen_secret" + ], + "returncode": 2, + "stderr": "usage: xa [-h] {list,spawn,gen-secret,archive} ...\nxa: error: argument {list,spawn,gen-secret,archive}: invalid choice: 'gen_secret' (choose from list, spawn, gen-secret, archive)\n", + "stdout": "", + "tier": 1, + "usage": "usage: xa [-h] {list,spawn,gen-secret,archive} ... xa: error: argument {list,spawn,gen-secret,archive}: invalid choice: 'gen_secret' (choose from list, spawn, gen-secret, archive)" + }, + { + "argv": [ + "archive" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: xa [-h] {list,spawn,gen-secret,archive} ...\n", + "tier": 1, + "usage": "usage: xa [-h] {list,spawn,gen-secret,archive} ..." + }, + { + "argv": [ + "archive", + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: xa archive [-h] {list,log} ...\n\npositional arguments:\n {list,log} Postmortem archive (list, log, forensics).\n list List archived sessions.\n log Show an archived session's log.\n\noptions:\n -h, --help show this help message and exit\n", + "tier": 3, + "usage": "usage: xa archive [-h] {list,log} ..." + }, + { + "argv": [ + "archive", + "list" + ], + "returncode": 0, + "stderr": "", + "stdout": "archive_list_cmd(pattern='*')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "archive", + "list", + "--pattern", + "x*" + ], + "returncode": 0, + "stderr": "", + "stdout": "archive_list_cmd(pattern='x*')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "archive", + "log", + "s1" + ], + "returncode": 0, + "stderr": "", + "stdout": "archive_log_cmd('s1')\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "archive", + "nope" + ], + "returncode": 2, + "stderr": "usage: xa archive [-h] {list,log} ...\nxa archive: error: argument {list,log}: invalid choice: 'nope' (choose from list, log)\n", + "stdout": "", + "tier": 1, + "usage": "usage: xa archive [-h] {list,log} ... xa archive: error: argument {list,log}: invalid choice: 'nope' (choose from list, log)" + } + ], + "cw_golden": 1, + "env": { + "COLUMNS": "100", + "NO_COLOR": "1", + "PYTHONHASHSEED": "0", + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + "TERM": "dumb" + }, + "models": "t/xa", + "newlines": "lf", + "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", + "pins": "A mapping key names the command verbatim-then-hyphenated, so the non-identifier key 'gen-secret' works and needs no __name__ mutation. Two commands are both called `list` in different dicts, which a list of functions cannot express. And the group's `group_kwargs={'help': ...}` displays nothing -- argh reads `title` for that row -- so translating it would be an improvement, and therefore a break.", + "prog": [ + "xa" + ], + "recorded_with": { + "python": "3.12.12", + "tool": "argh", + "version": "0.31.3" + }, + "rows": [ + 1, + 4 + ], + "shape": "xa" +} diff --git a/misc/record_goldens.py b/misc/record_goldens.py new file mode 100644 index 0000000..7986309 --- /dev/null +++ b/misc/record_goldens.py @@ -0,0 +1,129 @@ +"""Record the argh goldens for :func:`cw.testing.parity`. **Developer-only. Run once.** + + python misc/record_goldens.py # record every shape + python misc/record_goldens.py theremin xa # or just these + +``argh`` is not a runtime dependency of cw, and it is not a **test** dependency either. +That is deliberate and it is the reason this script lives under ``misc/`` rather than under +``tests/``: the same landing window ships ``wads licence-check``, and a cw whose own test +suite pulled LGPL-3.0-or-later argh would be that tool's first and most embarrassing +finding. Recording is a one-time act on a machine that happens to have argh installed. The +committed goldens carry no argh code -- only the bytes argh produced. + +What is recorded, per case: exit code, stdout, stderr, the normalised ``usage:`` line, and +(for ``--help`` cases) the full help body as a tier-3 snapshot. The environment is pinned by +:data:`cw.testing.RECORDING_ENV`, so re-recording on a second machine with the same argh and +Python produces a byte-identical file. + +To re-record after changing a fixture:: + + pip install 'argh==0.31.3' + python misc/record_goldens.py + git diff cw/tests/goldens/ # review EVERY line: this is the thing being asserted +""" + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import argh # noqa: E402 - the whole point of this file +import importlib.metadata # noqa: E402 + +from cw.testing import ( # noqa: E402 + GOLDEN_VERSION, + RECORDING_ENV, + _record, + capture, + pinned_env, + write_golden, +) +from cw.tests import fixtures # noqa: E402 + +#: The argh this corpus is recorded against. A different one is not automatically wrong, +#: but it is automatically a decision, so the script refuses rather than guessing. +PINNED_ARGH = "0.31.3" + +GOLDENS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "cw", + "tests", + "goldens", +) + + +def argh_outcome(shape, argv) -> dict: + """Run one case through argh, capturing what a shell would see. + + The parser is rebuilt per case on purpose. argh's ``@arg`` mutates the function it + decorates, and ``add_commands`` mutates the parser; a parser reused across cases would + be recording the accumulated state of the previous ones. + """ + fixtures.use_command_error(argh.CommandError) + parser = shape.argh_build(argh, argparse) + return capture(lambda: _dispatch(parser, argv)) + + +def _dispatch(parser, argv) -> int: + """``argh.dispatch``, normalised to "returns an exit code". + + argh's ``dispatch`` returns ``None`` and lets ``SystemExit`` out; :func:`capture` turns + the ``SystemExit`` into a code, and this turns the ``None`` into ``0``. Together they + reproduce what the interpreter does with a console script's ``main()``. + """ + argh.dispatch(parser, list(argv)) + return 0 + + +def record(shape) -> dict: + """The golden for one shape.""" + with pinned_env(): + cases = [ + _record(lambda a: argh_outcome(shape, a), argv) for argv in shape.cases + ] + return { + "cw_golden": GOLDEN_VERSION, + "shape": shape.name, + "models": shape.models, + "pins": shape.pins, + "rows": sorted(shape.rows), + "prog": [shape.prog], + "env": dict(RECORDING_ENV), + "newlines": "lf", + "recorded_with": { + "tool": "argh", + "version": importlib.metadata.version("argh"), + "python": ".".join(str(n) for n in sys.version_info[:3]), + }, + "note": ( + "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not " + "a dependency of cw; see misc/record_goldens.py." + ), + "cases": cases, + } + + +def main(argv=None) -> int: + """Record the named shapes, or all of them.""" + names = list(argv if argv is not None else sys.argv[1:]) or list(fixtures.SHAPES) + found = importlib.metadata.version("argh") + if found != PINNED_ARGH: + print( + f"refusing to record: this corpus is pinned to argh {PINNED_ARGH}, " + f"but argh {found} is installed. Install the pinned version, or change " + f"PINNED_ARGH deliberately and re-record everything.", + file=sys.stderr, + ) + return 1 + for name in names: + shape = fixtures.shape_named(name) + golden = record(shape) + path = os.path.join(GOLDENS_DIR, f"{shape.name}.json") + write_golden(golden, path) + print(f"{shape.name:12s} {len(golden['cases']):3d} cases -> {path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index b8fd561..d04be22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,13 +27,20 @@ resource = [ completion = [ "argcomplete>=3", ] -dev = [ +# What CI installs. Deliberately NO argh: `python -m cw.testing parity` -- the definition +# of v1 -- runs against committed goldens and must prove it needs nothing but cw. The +# `tests/argh_parity` differential skips itself when argh is absent. +test = [ "pytest>=7.0", "pytest-cov>=4.0", +] +# What a developer installs. argh is here and ONLY here: `tests/argh_parity` builds the +# same parser with argh and with cw and diffs them, and `misc/record_goldens.py` records +# the parity goldens. cw itself never imports argh (LGPL-3.0-or-later; cw is MIT), and +# `tests/test_import_is_cheap.py` enforces that. +dev = [ + "cw[test]", "ruff>=0.1.0", - # TEST ONLY, and deliberately not a runtime dependency: `tests/argh_parity` builds - # the same parser with argh and with cw and diffs them. cw itself never imports argh - # (LGPL-3.0-or-later; cw is MIT), and `tests/test_import_is_cheap.py` enforces that. "argh==0.31.3", ] docs = [ diff --git a/tests/argh_parity/conftest.py b/tests/argh_parity/conftest.py new file mode 100644 index 0000000..5ff60f6 --- /dev/null +++ b/tests/argh_parity/conftest.py @@ -0,0 +1,16 @@ +"""Skip the live-argh differential when argh is not installed. + +`argh` is a **dev** extra, never a test one: cw's CI installs `cw[test]`, which has no argh +in it, precisely so that `python -m cw.testing parity` proves it needs nothing but cw. This +directory is the other half of the story -- it builds the same parser with argh and with cw +and diffs them, which of course needs argh -- so it removes itself rather than erroring at +collection time. + +Run it with `pip install -e '.[dev]'`. Without that, `pytest` is still green; it just is not +asserting the thing this directory asserts, and the skip line says so. +""" + +import importlib.util + +#: Everything here needs argh at import time, so the skip has to happen at collection. +collect_ignore_glob = [] if importlib.util.find_spec("argh") else ["*.py"] diff --git a/tests/test_cli.py b/tests/test_cli.py index f43777f..831778c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -435,16 +435,31 @@ def test_the_argparse_import_perimeter(): """ package = pathlib.Path(cw.__file__).parent importers = { - path.name - for path in sorted(package.glob("*.py")) + str(path.relative_to(package)) + for path in sorted(package.rglob("*.py")) if re.search(r"^\s*(import argparse|from argparse)", path.read_text(), re.M) } - assert importers == {"base.py", "cli.py"}, ( - "expected argparse only in base.py and cli.py " - "(compat.py and testing.py join them when they land)" + assert importers == {"base.py", "cli.py", "compat.py", "testing.py"}, ( + "expected argparse only in base.py, cli.py, compat.py and testing.py -- " + "grammar, convention, commands, ingress and egress must stay free of it, so " + "type inference and call wiring cannot quietly re-fuse with the parser" ) +def test_the_parity_fixtures_take_argparse_as_an_argument_rather_than_importing_it(): + """`cw/tests/fixtures.py` ships inside the package, so it lives under the same rule. + + It needs both ``argparse`` and ``argh`` to describe how argh would have built each + shape, and it takes them as *parameters* -- which is what lets the fixtures ship with + cw while the argh they describe stays a developer-only install. + """ + from cw.tests import fixtures + + source = pathlib.Path(fixtures.__file__).read_text() + assert not re.search(r"^\s*(import argh|import argparse)", source, re.M) + assert "def argh_build" in source or "argh_build=lambda argh, argparse" in source + + def test_a_group_built_with_another_convention_keeps_it(): """The stash is per subcommand, so a mixed-convention parser stays coherent.""" diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..e2ee3b3 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,478 @@ +"""`cw.compat` -- the transitional argh shim, and the four repairs it must not un-repair. + +The tests that matter here are the ones asserting cw.compat is NOT a literal transcription +of argh's signatures: a migrated console script must keep exiting 2 on a usage error, must +be capturable, and must accept the keyword argh renamed without an alias. +""" + +import argparse +import inspect +import io +import subprocess +import sys +import warnings + +import pytest + +import cw +from cw import compat + + +@pytest.fixture(autouse=True) +def _quiet(monkeypatch): + """Most tests are not about the deprecation warning; the ones that are opt back in.""" + monkeypatch.setenv(compat.QUIET_ENV, "1") + + +def hello(name, *, loudly=False): + """Greet someone.""" + return f"HELLO {name}" if loudly else f"hello {name}" + + +def add(a: int, b: int): + """Add two numbers.""" + return a + b + + +# ======================================================================================= +# The design gauge: every shim is thin +# ======================================================================================= + + +class TestTheShimsAreThin: + """Spec section 10's gauge: if a shim needed real work, the real API would be wrong.""" + + #: The eleven names, plus the two the module docstring counts separately. + SHIMS = ( + "add_commands", + "arg", + "confirm", + "dispatch", + "dispatch_command", + "dispatch_commands", + "set_default_command", + ) + + @pytest.mark.parametrize("name", SHIMS) + def test_each_shim_is_three_statements_or_fewer(self, name): + import ast + import textwrap + + func = getattr(compat, name) + func = getattr(func, "__wrapped__", func) # past @_deprecated + tree = ast.parse(textwrap.dedent(inspect.getsource(func))) + body = tree.body[0].body + statements = [ + node for node in body if not isinstance(node, (ast.Expr, ast.FunctionDef)) + ] + [n for n in body if isinstance(n, ast.FunctionDef)] + assert len(statements) <= 3, f"{name} has {len(statements)} statements" + + def test_every_measured_argh_name_is_present(self): + """The fleet's whole measured surface, by name.""" + for name in ( + "dispatch_commands", + "dispatch_command", + "ArghParser", + "dispatch", + "arg", + "add_commands", + "NameMappingPolicy", + "CommandError", + "completion", + "confirm", + "set_default_command", + ): + assert hasattr(compat, name), name + + +# ======================================================================================= +# Repair 1: the shims must not swallow argparse's exit code +# ======================================================================================= + + +class TestExitCodes: + """A migrated console script must not start exiting 0 on a bad command line.""" + + def test_a_usage_error_exits_2(self): + with pytest.raises(SystemExit) as caught: + compat.dispatch_commands([add], ["nope"], output_file=io.StringIO()) + assert caught.value.code == 2 + + def test_a_usage_error_through_dispatch_command_exits_2(self): + with pytest.raises(SystemExit) as caught: + compat.dispatch_command(add, ["nope", "--bad"], output_file=io.StringIO()) + assert caught.value.code == 2 + + def test_success_returns_None_like_argh(self): + assert ( + compat.dispatch_commands( + [add], ["add", "1", "2"], output_file=io.StringIO() + ) + is None + ) + + def test_a_command_error_keeps_its_own_code(self): + def boom(): + """Fail on purpose.""" + raise compat.CommandError("no", code=7) + + with pytest.raises(SystemExit) as caught: + compat.dispatch_commands( + [boom], ["boom"], output_file=io.StringIO(), errors_file=io.StringIO() + ) + assert caught.value.code == 7 + + def test_the_recorded_argh_golden_agrees_that_a_usage_error_is_2(self): + """Not an opinion: the committed argh recording says so.""" + import json + import os + + from cw import testing + + path = os.path.join(testing.DFLT_GOLDENS_DIR, "priv.json") + golden = json.load(open(path, encoding="utf-8")) + nope = next(c for c in golden["cases"] if c["argv"] == ["nope"]) + assert nope["returncode"] == 2 + + def test_as_a_real_console_script(self): + """End to end, because `$?` is what CI actually checks.""" + script = ( + "from cw import compat as argh\n" + "def add(a: int, b: int):\n" + " 'Add.'\n" + " return a + b\n" + "argh.dispatch_commands([add])\n" + ) + for argv, expected in ((["add", "2", "3"], 0), (["nope"], 2)): + done = subprocess.run( + [sys.executable, "-c", script, *argv], capture_output=True, text=True + ) + assert done.returncode == expected, (argv, done.stdout, done.stderr) + + +# ======================================================================================= +# Repairs 2 and 3: streams resolve at call time, and dispatch keywords are split out +# ======================================================================================= + + +class TestStreams: + """argh's worst defect, not reintroduced into the shim 19 call sites will use.""" + + @pytest.mark.parametrize("name", ["output_file", "errors_file"]) + def test_no_stream_is_bound_in_a_signature_default(self, name): + """`inspect.signature(...).parameters[name].default is not sys.stdout`.""" + for func in ( + compat.dispatch, + compat.dispatch_commands, + compat.dispatch_command, + ): + parameters = inspect.signature(func).parameters + if name in parameters: + assert parameters[name].default is not getattr( + sys, name[:-5] or "stdout" + ) + + def test_output_file_None_returns_the_string(self): + assert ( + compat.dispatch_commands([add], ["add", "2", "3"], output_file=None) + == "5\n" + ) + + def test_output_file_is_honoured_when_given(self): + buffer = io.StringIO() + compat.dispatch_commands([add], ["add", "2", "3"], output_file=buffer) + assert buffer.getvalue() == "5\n" + + def test_errors_file_is_honoured_when_given(self): + def boom(): + """Fail on purpose.""" + raise compat.CommandError("nope") + + errors = io.StringIO() + with pytest.raises(SystemExit): + compat.dispatch_commands( + [boom], ["boom"], output_file=io.StringIO(), errors_file=errors + ) + assert errors.getvalue() == "CommandError: nope\n" + + def test_an_argh_dispatch_keyword_does_not_reach_ArgumentParser(self): + """Repair 3: `output_file=` must not become a TypeError from ArgumentParser.""" + assert ( + compat.dispatch_commands([add], ["add", "1", "1"], output_file=None) + == "2\n" + ) + + def test_a_parser_keyword_still_reaches_ArgumentParser(self): + out = compat.dispatch_commands([add], ["--help"], prog="calc", output_file=None) + assert out.startswith("usage: calc") + + def test_the_unused_argh_keywords_are_accepted_rather_than_crashing(self): + """Zero fleet sites pass one, but argh's signature accepts them all.""" + out = compat.dispatch_commands( + [add], + ["add", "1", "1"], + output_file=None, + raw_output=False, + always_flush=False, + skip_unknown_args=False, + add_help_command=False, + ) + assert out == "2\n" + + def test_dispatch_rejects_a_build_time_keyword_with_a_message_that_helps(self): + parser = compat.ArghParser(prog="x") + with pytest.raises(TypeError, match="cw.mk_parser"): + compat.dispatch(parser, [], prog="other") + + +# ======================================================================================= +# Repair 4: both spellings of the group keyword +# ======================================================================================= + + +class TestBothGroupSpellings: + """argh 0.30 renamed `namespace=` to `group_name=` with no alias. Four dead call sites.""" + + def _usage(self, **kwargs): + parser = argparse.ArgumentParser(prog="priv") + compat.add_commands(parser, [hello], **kwargs) + return parser.format_usage() + + def test_group_name_and_namespace_are_equivalent(self): + assert self._usage(group_name="git_ops") == self._usage(namespace="git_ops") + + def test_the_group_really_appears(self): + assert "{git_ops}" in self._usage(namespace="git_ops") + + def test_group_kwargs_and_namespace_kwargs_are_equivalent(self): + title = {"title": "Git operations"} + assert self._usage(group_name="g", group_kwargs=title) == self._usage( + namespace="g", namespace_kwargs=title + ) + + +# ======================================================================================= +# arg, NameMappingPolicy, ArghParser, completion, confirm +# ======================================================================================= + + +class TestArg: + """`@arg` writes the function-attribute tier, and is thin because that tier exists.""" + + def test_it_writes_the_documented_attribute(self): + @compat.arg("-i", "--ignore", nargs="*") + def quickstart(project_dir, *, ignore=None): + """Start a project.""" + + assert quickstart._cw == { + "params": {"ignore": {"nargs": "*", "flags": ["-i", "--ignore"]}} + } + + def test_decorator_order_matches_arghs(self): + """argh's outermost decorator inserts before the innermost.""" + + @compat.arg("--alpha") + @compat.arg("--beta") + def f(*, alpha=None, beta=None): + """Two options.""" + + assert list(f._cw["params"]) == ["alpha", "beta"] + + def test_the_declaration_reaches_the_parser(self): + @compat.arg("--ignore", nargs="*") + def quickstart(project_dir, *, ignore=None): + """Start a project.""" + + # The `nargs='*'` really took: a bare `--ignore` parses to `[]`, epythet's + # CI-critical case, and argparse renders the inferred short first. + parser = cw.mk_parser(quickstart, prog="epythet") + assert "[-i [IGNORE ...]]" in parser.format_usage() + assert parser.parse_args(["x", "--ignore"]).ignore == [] + + def test_a_lone_short_flag_names_the_parameter_the_way_argh_does(self): + """argh's `naive_guess_func_arg_name`: one flag IS the name, dashes stripped.""" + + @compat.arg("-i") + def f(*, i=None): + """One option.""" + + assert list(f._cw["params"]) == ["i"] + + def test_two_shorts_and_no_long_flag_says_what_argh_needs(self): + with pytest.raises(ValueError, match="long flag"): + compat.arg("-i", "-x")(lambda: None) + + +class TestNameMappingPolicy: + """Exported, which argh's own is not.""" + + def test_it_is_exported(self): + assert hasattr(compat, "NameMappingPolicy") + + def test_its_values_are_cws_naming_strings(self): + assert ( + compat.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT == cw.BY_NAME_IF_HAS_DEFAULT + ) + assert compat.NameMappingPolicy.BY_NAME_IF_KWONLY == cw.BY_NAME_IF_KWONLY + + def test_it_actually_changes_the_grammar(self): + def f(alpha, beta=1): + """Two parameters.""" + + by_default = compat.dispatch_command(f, ["--help"], output_file=None) + by_kwonly = compat.dispatch_command( + f, + ["--help"], + output_file=None, + name_mapping_policy=compat.NameMappingPolicy.BY_NAME_IF_KWONLY, + ) + assert "[-b BETA]" in by_default + assert "[beta]" in by_kwonly + + def test_add_commands_takes_it_too(self): + parser = argparse.ArgumentParser(prog="x") + compat.add_commands( + parser, + [hello], + name_mapping_policy=compat.NameMappingPolicy.BY_NAME_IF_KWONLY, + ) + assert "{hello}" in parser.format_usage() + + +class TestArghParser: + """The 27 call sites that hold a parser object.""" + + def test_the_three_methods_work_together(self): + parser = compat.ArghParser(prog="demo", description="A demo.") + parser.add_commands([hello, add]) + assert parser.dispatch(["hello", "world"], output_file=None) == "hello world\n" + assert parser.dispatch(["add", "2", "3"], output_file=None) == "5\n" + + def test_set_default_command_makes_a_single_command_cli(self): + parser = compat.ArghParser(prog="greet") + parser.set_default_command(hello) + assert ( + parser.dispatch(["world", "--loudly"], output_file=None) == "HELLO world\n" + ) + + def test_it_is_an_argparse_parser(self): + assert isinstance(compat.ArghParser(), argparse.ArgumentParser) + + def test_but_mk_parser_still_returns_a_PLAIN_one(self): + """The property argcomplete depends on is not weakened by the shim's existence.""" + assert type(cw.mk_parser(hello)) is argparse.ArgumentParser + + +class TestCompletionAndConfirm: + def test_completion_autocomplete_is_reachable_as_a_submodule_would_be(self): + parser = cw.mk_parser(hello, prog="x") + assert compat.completion.autocomplete(parser) in (True, False) + + def test_confirm_forwards_to_cw(self): + assert compat.confirm("Go", skip=True) is None + + def test_confirm_reads_an_answer(self): + assert compat.confirm("Go", in_=io.StringIO("y\n"), out=io.StringIO()) is True + + +# ======================================================================================= +# The three names that are deliberately absent +# ======================================================================================= + + +class TestNotShipped: + """A shim that quietly does the wrong thing is worse than the AttributeError.""" + + @pytest.mark.parametrize("name", ["named", "aliases", "add_subcommands"]) + def test_it_raises_and_explains(self, name): + with pytest.raises(AttributeError) as caught: + getattr(compat, name) + message = str(caught.value) + assert "does not ship" in message and "zero uses" in message + assert len(message) > 100, "an informative message, not a bare one" + + def test_an_ordinary_missing_name_gets_an_ordinary_message(self): + with pytest.raises(AttributeError, match="has no attribute 'wibble'"): + compat.wibble + + def test_named_would_have_been_a_silent_no_op(self): + """Why it is absent: nothing reads `func._cw['name']`, including cw.commands.""" + + def do_load(): + """Load.""" + + do_load._cw = {"name": "load"} + assert "do-load" in cw.mk_parser([do_load], prog="x").format_usage() + + +# ======================================================================================= +# Deprecation, and the monkeypatch coact depends on +# ======================================================================================= + + +class TestDeprecation: + def test_each_name_warns_once(self, monkeypatch): + monkeypatch.delenv(compat.QUIET_ENV, raising=False) + monkeypatch.setattr(compat, "_WARNED", set()) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + compat.dispatch_commands([add], ["add", "1", "1"], output_file=None) + compat.dispatch_commands([add], ["add", "1", "1"], output_file=None) + assert len(caught) == 1 + assert issubclass(caught[0].category, DeprecationWarning) + assert "transitional argh shim" in str(caught[0].message) + + def test_the_env_var_silences_it(self, monkeypatch): + monkeypatch.setenv(compat.QUIET_ENV, "1") + monkeypatch.setattr(compat, "_WARNED", set()) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + compat.dispatch_commands([add], ["add", "1", "1"], output_file=None) + assert caught == [] + + +class TestTheOneLineMigration: + """`from cw import compat as argh` -- what the diff actually has to survive.""" + + def test_monkeypatching_cli_argh_dispatch_commands_still_lands(self, monkeypatch): + """coact/tests/test_cli.py:162 depends on this, and Wave 0 must not break it.""" + import types + + module = types.ModuleType("fake_cli") + module.argh = compat + calls = [] + monkeypatch.setattr( + module.argh, "dispatch_commands", lambda *a, **k: calls.append(a) + ) + module.argh.dispatch_commands([add], ["add"]) + assert calls == [([add], ["add"])] + + def test_the_module_answers_to_arghs_name(self): + from cw import compat as argh + + assert argh.CommandError is cw.CommandError + assert callable(argh.dispatch_commands) + assert callable(argh.arg) + + +class TestFuncKwargs: + """argh's `func_kwargs` is accepted, and refused out loud rather than mis-translated. + + It is a mapping of *ArgumentParser* keywords applied to every subcommand, not + add_argument keywords, so it is not a cw `config`. Zero fleet call sites pass it. It + stays in the signature so a call site naming it does not die on a TypeError at + import-swap time, and says what it does not do if anyone actually uses it. + """ + + def test_it_is_in_the_signature(self): + assert "func_kwargs" in inspect.signature(compat.add_commands).parameters + + def test_passing_None_or_nothing_is_fine(self): + parser = compat.ArghParser(prog="x") + compat.add_commands(parser, [hello], func_kwargs=None) + assert "{hello}" in parser.format_usage() + + def test_actually_using_it_says_plainly_that_it_is_not_implemented(self): + parser = compat.ArghParser(prog="x") + with pytest.raises(NotImplementedError, match="no fleet call site passes it"): + compat.add_commands(parser, [hello], func_kwargs={"help": "hi"}) diff --git a/tests/test_corpus_coverage.py b/tests/test_corpus_coverage.py new file mode 100644 index 0000000..ba4729c --- /dev/null +++ b/tests/test_corpus_coverage.py @@ -0,0 +1,344 @@ +"""The corpus's own audit: every argh-contract row is covered, and by something real. + +Issue #6 asks for a coverage test that maps row -> case and fails on an uncovered row. This +is that test, plus the thing that makes it honest: three of the twenty rows are **not** +assertable by `python -m cw.testing parity`, because they are only observable in a `--help` +body and the golden format puts help bodies in tier 3 (snapshot, never asserted). Those +three name the test that does cover them instead, and the test checks that that test exists. + +A coverage map that silently counted a help-only row as "covered by parity" would be the +most expensive kind of wrong: it would read green while asserting nothing. +""" + +import json +import os + +import pytest + +from cw import testing +from cw.tests import fixtures + +#: Canonical spec section 9's compatibility contract, in full. The text is the row, the +#: value is where its coverage actually comes from. +CONTRACT_ROWS = { + 1: "command name = __name__ with _ -> -", + 2: "group name used verbatim (priv git_ops)", + 3: "defaulted POSITIONAL_OR_KEYWORD -> an --option", + 4: "short flags from first char, suppressed on collision; -h stripped", + 5: "flags = inferred + [declared not already present]", + 6: "bool default True -> store_false", + 7: "list/tuple default -> nargs='*'", + 8: "other non-None default -> type=type(default)", + 9: "choices[0] -> type", + 10: "*args -> positional nargs='*'", + 11: "**kwargs contributes no CLI args", + 12: "one @arg disables hints for the WHOLE function", + 13: "annotations read raw (PEP 563 blind)", + 14: "help defaults to '%(default)s'", + 15: "help renders repr(default), None -> '-'", + 16: "egress whitelist: only GeneratorType/list/tuple iterate; dict on ONE line", + 17: "None prints nothing; 0/False/'' DO print", + 18: "generators stream lazily", + 19: "CommandError -> '{cls}: {msg}' on stderr, SystemExit(code)", + 20: "a group's listing row reads group_kwargs['title'], NOT ['help']", +} + +#: The three rows `parity` cannot assert, and the test that does assert each one. They are +#: help-rendering rows: the only place they show is a `--help` body, and a `--help` body is +#: tier 3 -- recorded and diffed advisorily, never asserted, because it wraps to COLUMNS and +#: changes shape between Python versions (3.13 reformatted argparse's option column). +#: +#: Each is instead covered by the dev-machine differential, which builds the same parser +#: with argh and with cw *in the same process at the same Python* and asserts the rendered +#: help is byte-identical -- a comparison a committed golden cannot make. +HELP_ONLY_ROWS = { + 14: "tests/argh_parity/test_parity.py::TestHelp", + 15: "tests/argh_parity/test_parity.py::TestHelp", + 20: "tests/argh_parity/test_cli_parity.py", +} + + +def _rows_covered_by_parity(): + """Every contract row a fixture shape claims to pin.""" + return set().union(*(shape.rows for shape in fixtures.SHAPES.values())) + + +class TestRowCoverage: + """Issue #6: every row covered by >= 1 case, and the map is checked, not asserted.""" + + def test_every_row_is_covered_by_parity_or_by_a_named_differential(self): + covered = _rows_covered_by_parity() | set(HELP_ONLY_ROWS) + missing = sorted(set(CONTRACT_ROWS) - covered) + assert not missing, "\n".join(f"row {n}: {CONTRACT_ROWS[n]}" for n in missing) + + def test_the_parity_gate_asserts_seventeen_of_the_twenty_rows(self): + """A number worth stating, because "all 20" would be a lie about three of them.""" + assert len(_rows_covered_by_parity()) == 17 + assert len(HELP_ONLY_ROWS) == 3 + + @pytest.mark.parametrize("row,where", sorted(HELP_ONLY_ROWS.items())) + def test_a_help_only_rows_named_covering_test_exists(self, row, where): + """The escape hatch is only honest if the test it points at is really there.""" + path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), where.split("::")[0] + ) + missing = f"row {row} points at a test file that is not there" + assert os.path.exists(path), missing + + def test_no_shape_claims_a_row_that_is_not_in_the_contract(self): + for shape in fixtures.SHAPES.values(): + unknown = shape.rows - set(CONTRACT_ROWS) + complaint = f"{shape.name} claims non-existent row(s) {sorted(unknown)}" + assert not unknown, complaint + + def test_no_shape_claims_a_row_it_could_not_reach(self): + """A help-only row claimed by a shape would be a claim `parity` cannot honour.""" + for shape in fixtures.SHAPES.values(): + claimed = shape.rows & set(HELP_ONLY_ROWS) + assert not claimed, ( + f"{shape.name} claims help-rendering row(s) {sorted(claimed)}, which tier 3 " + f"records but never asserts" + ) + + +class TestTheCorpusIsReal: + """Issue #6: every case is a file in the repo with a derivation, not a number in a doc.""" + + def test_the_case_count_is_counted_rather_than_asserted(self): + counted = sum(len(shape.cases) for shape in fixtures.SHAPES.values()) + assert fixtures.case_count() == counted + assert counted >= 120, "the corpus should not silently shrink" + + def test_the_readme_states_the_real_count_and_the_real_per_shape_counts(self): + """Issue #6: the count is stated in cw/tests/README.md and matched by a test. + + The spec's "7 repos / 214 cases" was a number in a document with no file behind it. + This is the check that stops the replacement becoming the same thing. + """ + readme = open( + os.path.join(os.path.dirname(testing.DFLT_GOLDENS_DIR), "README.md"), + encoding="utf-8", + ).read() + assert ( + f"{len(fixtures.SHAPES)} shapes / {fixtures.case_count()} cases" in readme + ) + for shape in fixtures.SHAPES.values(): + assert f"| `{shape.name}` | " in readme, f"{shape.name} is not in the table" + row = next( + line for line in readme.splitlines() if f"| `{shape.name}` |" in line + ) + assert row.rstrip().endswith(f"| {len(shape.cases)} |"), row + + def test_every_shape_says_which_repo_it_models_and_what_it_pins(self): + for shape in fixtures.SHAPES.values(): + assert shape.models, shape.name + assert len(shape.pins) > 80, f"{shape.name}'s `pins` is not a derivation" + + def test_every_shape_has_a_committed_golden(self): + for name in fixtures.SHAPES: + path = os.path.join(testing.DFLT_GOLDENS_DIR, f"{name}.json") + assert os.path.exists(path), f"{name} has no recorded golden" + + def test_every_golden_has_a_shape_and_every_shape_has_a_golden(self): + recorded = { + json.load( + open(os.path.join(testing.DFLT_GOLDENS_DIR, name), encoding="utf-8") + )["shape"] + for name in os.listdir(testing.DFLT_GOLDENS_DIR) + if name.endswith(".json") + } + assert recorded == set(fixtures.SHAPES) + + def test_every_shape_has_a_no_arguments_case_and_a_help_case(self): + """The two argvs every CLI is asked first, and the two most likely to regress.""" + for shape in fixtures.SHAPES.values(): + assert () in shape.cases, f"{shape.name} has no bare-invocation case" + assert ("--help",) in shape.cases, f"{shape.name} has no --help case" + + @pytest.mark.parametrize("name", sorted(fixtures.SHAPES)) + def test_every_shape_records_real_command_output(self, name): + """The guard for a bug that really happened, and was invisible without it. + + `capture` rebinds `sys.stdout` around a run. An early version forgot to flush the + stream it *displaced* -- the one argh captured at import -- so recording through a + pipe or a shell redirect drained that buffer after the descriptor was restored and + wrote goldens whose every command produced no output at all. Every structural check + still passed: the case count, the exit codes, the usage lines, the provenance. Only + this one would have failed, so it exists. + """ + golden = testing.load_golden( + os.path.join(testing.DFLT_GOLDENS_DIR, f"{name}.json") + ) + substantive = [ + case + for case in golden["cases"] + if case["tier"] == 1 + and case["stdout"] + and not case["stdout"].startswith("usage:") + ] + assert len(substantive) >= 3, ( + f"{name}'s golden has almost no command output -- it was probably recorded " + f"through a pipe by a cw.testing.capture that lost the displaced stream's buffer" + ) + + def test_every_shape_has_at_least_one_failing_case(self): + """A corpus of only happy paths asserts half a CLI.""" + for name in fixtures.SHAPES: + golden = testing.load_golden( + os.path.join(testing.DFLT_GOLDENS_DIR, f"{name}.json") + ) + codes = {case["returncode"] for case in golden["cases"]} + assert codes - {0}, f"{name} has no case with a non-zero exit code" + + +class TestTheGoldensCarryTheirProvenance: + """Issue #7: a golden must say what recorded it, so a stale one can be spotted.""" + + @pytest.mark.parametrize("name", sorted(fixtures.SHAPES)) + def test_each_golden_names_argh_its_version_and_the_pinned_env(self, name): + golden = testing.load_golden( + os.path.join(testing.DFLT_GOLDENS_DIR, f"{name}.json") + ) + assert golden["recorded_with"]["tool"] == "argh" + assert golden["recorded_with"]["version"] == "0.31.3" + assert golden["recorded_with"]["python"] + assert golden["env"]["COLUMNS"] == testing.DFLT_COLUMNS + assert golden["newlines"] == "lf" + + @pytest.mark.parametrize("name", sorted(fixtures.SHAPES)) + def test_no_golden_carries_a_windows_newline(self, name): + """The format stores LF; the comparator is what handles CRLF at replay time.""" + path = os.path.join(testing.DFLT_GOLDENS_DIR, f"{name}.json") + assert b"\\r" not in open(path, "rb").read() + + @pytest.mark.parametrize("name", sorted(fixtures.SHAPES)) + def test_every_golden_is_pure_ascii(self, name): + """So the Windows console's code page can never become a question. + + The fixtures deliberately emit nothing outside ASCII. That is not a limitation of + the harness -- `PYTHONUTF8` and `PYTHONIOENCODING` are pinned for exactly this -- + but it removes the last variable between a golden recorded on a Mac and the same + golden asserted on a cp1252 console. + """ + path = os.path.join(testing.DFLT_GOLDENS_DIR, f"{name}.json") + open(path, encoding="ascii").read() # raises UnicodeDecodeError if not + + @pytest.mark.parametrize("name", sorted(fixtures.SHAPES)) + def test_no_golden_carries_a_machine_specific_address(self, name): + path = os.path.join(testing.DFLT_GOLDENS_DIR, f"{name}.json") + text = open(path, encoding="utf-8").read() + assert "0xADDR" not in text or " at 0x1" not in text + assert testing.scrub_addresses(text) == text + + +class TestArghIsNotADependency: + """Issue #7's headline, asserted by the environment rather than by convention.""" + + def test_nothing_under_cw_imports_argh(self): + import pathlib + + root = pathlib.Path(testing.__file__).parent + for path in root.rglob("*.py"): + source = path.read_text(encoding="utf-8") + for line in source.splitlines(): + offends = line.startswith(("import argh", "from argh")) + assert not offends, f"{path}: {line}" + + def test_argh_is_not_a_runtime_dependency_nor_in_the_test_extra(self): + """Issue #7: CI installs `cw[test]`, and `cw[test]` must not pull LGPL argh.""" + import pathlib + import tomllib + + root = pathlib.Path(testing.__file__).parent.parent + config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + project = config["project"] + assert project["dependencies"] == [] + extras = project["optional-dependencies"] + assert not any("argh" in dep for dep in extras["test"]) + # ... and it IS in `dev`, which is where the differential and the recorder live. + assert any(dep.startswith("argh==") for dep in extras["dev"]) + + def test_the_parity_gate_really_runs_with_no_argh_importable(self): + """The claim, run rather than declared: hide argh and see the gate still pass.""" + import subprocess + import sys + + blocker = ( + "import sys\n" + "class Block:\n" + " def find_spec(self, name, path=None, target=None):\n" + " if name.split('.')[0] == 'argh':\n" + " raise ImportError('argh is hidden for this test')\n" + " return None\n" + "sys.meta_path.insert(0, Block())\n" + "import io\n" + "from cw.testing import parity\n" + "out = io.StringIO()\n" + "code = parity(out=out)\n" + "print(code, out.getvalue().strip())\n" + ) + done = subprocess.run( + [sys.executable, "-c", blocker], capture_output=True, text=True + ) + assert done.returncode == 0, done.stderr + assert done.stdout.strip().startswith("0 ") + assert done.stdout.strip().endswith(": identical") + + def test_the_recorder_lives_outside_the_test_tree(self): + """Recording is a dev act. It must not be reachable from `pytest`.""" + import pathlib + + root = pathlib.Path(testing.__file__).parent.parent + assert (root / "misc" / "record_goldens.py").exists() + assert not (root / "tests" / "record_goldens.py").exists() + + +class TestTheFixtureModuleItself: + """The corpus's own small surface. The argh-side builders are covered by the recorder. + + `declare`, `_single_command`, `_subcommands` and `_mutated` describe how *argh* would + have built each shape, and only `misc/record_goldens.py` runs them -- which is the + point: they take the `argh` module as an argument so this file can ship inside `cw` + while argh stays a developer-only install. They are exercised whenever the goldens are + re-recorded, and their output is what every golden in `goldens/` is made of. + """ + + def test_a_shape_reprs_as_something_a_failure_message_can_use(self): + shown = repr(fixtures.THEREMIN) + assert "theremin" in shown and "cases" in shown and "rows" in shown + + def test_a_declaration_with_no_long_flag_says_what_argh_needs(self): + with pytest.raises(ValueError, match="cannot guess"): + fixtures._param_name_of(("-i", "-x")) + + def test_config_from_derives_the_cw_spelling_from_the_argh_one(self): + """One source for both spellings, so the two cannot drift apart.""" + derived = fixtures.config_from( + [(("-p", "--pipeline"), {"nargs": "?"}), (("--scale",), {"help": "h"})] + ) + assert derived == { + "pipeline": {"nargs": "?", "flags": ["-p"]}, + "scale": {"help": "h"}, + } + + def test_a_shape_without_config_resolves_to_no_config(self): + assert fixtures._resolved_config(fixtures.CONTRACT) is None + + def test_the_contract_shape_refuses_to_run_without_a_CommandError_installed(self): + """Neither side gets a default: a default here would record the wrong exception.""" + saved = list(fixtures._COMMAND_ERROR) + fixtures._COMMAND_ERROR.clear() + try: + with pytest.raises(RuntimeError, match="no CommandError class installed"): + fixtures.raises_command_error() + finally: + fixtures._COMMAND_ERROR[:] = saved + + def test_the_argh_only_partial_wrapper_matches_the_partial_it_stands_in_for(self): + """argh cannot take a functools.partial, so the golden uses a hand-written wrapper. + + The parity claim only means something if the two really do the same thing. + """ + assert fixtures.packages_from_all_setup_cfgs_for_argh( + "/p" + ) == fixtures.packages_from_all_setup_cfgs("/p") diff --git a/tests/test_import_is_cheap.py b/tests/test_import_is_cheap.py index 82a7e33..8acaef1 100644 --- a/tests/test_import_is_cheap.py +++ b/tests/test_import_is_cheap.py @@ -46,11 +46,15 @@ def test_no_module_scope_third_party_import_in_cw(): package_dir = pathlib.Path(cw.__file__).parent offenders = {} - for module_path in sorted(package_dir.glob("*.py")): + # rglob, not glob: `cw/tests/` ships inside the package (the parity fixtures and + # goldens), so it is subject to the same rule as every other module under `cw/`. + for module_path in sorted(package_dir.rglob("*.py")): for lineno, line in enumerate(module_path.read_text().splitlines(), start=1): if not line.startswith(("import ", "from ")): continue # indented -> inside a function or class; that is the allowed form root = line.split()[1].split(".")[0] if root in FORBIDDEN_AT_IMPORT: - offenders[f"{module_path.name}:{lineno}"] = line.strip() + offenders[f"{module_path.relative_to(package_dir)}:{lineno}"] = ( + line.strip() + ) assert not offenders, f"module-scope third-party imports: {offenders}" diff --git a/tests/test_main.py b/tests/test_main.py index 063a57b..6b7f5e6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -55,11 +55,30 @@ def test_help_prints_the_help_cw_would_print(): assert code == 0 and out.startswith("usage: write_lines") -def test_parity_says_so_until_cw_testing_lands(): - code, _, err = run(["parity"]) - assert code in (0, 1) - if code == 1: - assert "cw.testing" in err +def test_parity_runs_the_real_gate(capsys): + """`cw.testing` has landed, so this is now the gate itself, reached the other way. + + `capsys` rather than `run`'s buffers: cw redirects argparse's output, not a command + body's, so `parity`'s report goes where the process's own `print` goes. That is the + documented behaviour of `out=`, not an accident of this test. + """ + assert run(["parity"])[0] == 0 + assert capsys.readouterr().out.strip().endswith(": identical") + + +def test_parity_says_so_if_cw_testing_is_missing(monkeypatch): + """The honest failure, still asserted -- an installed cw could be missing its goldens. + + A `None` in `sys.modules` is the documented way to make an import of that name raise + `ImportError`. The attribute has to go too: once a submodule has been imported, `from + cw import testing` reads it off the package object and never consults `sys.modules` at + all -- so patching only the one leaves the import succeeding and the simulation false. + """ + monkeypatch.setitem(sys.modules, "cw.testing", None) + monkeypatch.delattr(cw, "testing", raising=False) + code, out, err = run(["parity"]) + assert code == 1 and out == "" + assert "cw.testing is not available" in err def test_parity_delegates_to_cw_testing_when_it_exists(monkeypatch): diff --git a/tests/test_testing.py b/tests/test_testing.py new file mode 100644 index 0000000..9692671 --- /dev/null +++ b/tests/test_testing.py @@ -0,0 +1,583 @@ +"""`cw.testing` -- the standalone harness, its D4 guarantee, and the parity gate. + +The round-trip test is the important one: characterize a toy CLI, mutate it, and assert +`replay` notices. A harness that cannot fail is worse than no harness, because it is +believed. +""" + +import ast +import io +import json +import os +import subprocess +import sys +import textwrap + +import pytest + +from cw import testing + +P = sys.executable + +#: What `cw/testing.py` is allowed to import at module scope. This is the D4 contract, and +#: it is the reason the file can be copied into a repo that will never depend on cw. +ALLOWED_MODULE_IMPORTS = { + "argparse", + "difflib", + "json", + "os", + "re", + "shlex", + "subprocess", + "sys", +} + + +# ======================================================================================= +# D4: the standalone guarantee, asserted by reading the file rather than by trusting it +# ======================================================================================= + + +def _module_scope_imports(path): + """Every module imported at module scope by ``path``, by top-level package name.""" + with open(path, "r", encoding="utf-8") as stream: + tree = ast.parse(stream.read()) + names = set() + for node in tree.body: # module scope ONLY -- a function-local import is the point + if isinstance(node, ast.Import): + names.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + names.add(node.module.split(".")[0]) + return names + + +class TestStandalone: + """D4: `cw/testing.py` is one file, stdlib-only, copyable into any repo.""" + + def test_module_scope_imports_are_exactly_the_allowed_stdlib_set(self): + found = _module_scope_imports(testing.__file__) + assert found == ALLOWED_MODULE_IMPORTS + + def test_it_imports_no_cw_and_no_i2_at_module_scope(self): + found = _module_scope_imports(testing.__file__) + assert "cw" not in found and "i2" not in found + + def test_parity_imports_cw_lazily_inside_the_function(self): + """The one cw import in the file is inside `parity`, which is what D4 allows.""" + source = open(testing.__file__, encoding="utf-8").read() + tree = ast.parse(source) + inside = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("cw") + ] + assert inside, "parity must import cw somewhere" + all_indented = all(node.col_offset > 0 for node in inside) + assert all_indented, "a cw import is at module scope" + + def test_the_file_really_runs_standalone(self, tmp_path): + """Copy it somewhere with no cw on the path and use it. That is the guarantee.""" + copy = tmp_path / "testing.py" + copy.write_text( + open(testing.__file__, encoding="utf-8").read(), encoding="utf-8" + ) + result = subprocess.run( + [ + P, + "-c", + "import sys, testing; " + "assert not [m for m in sys.modules if m.split('.')[0] == 'cw']; " + "print(testing.normalise_usage('usage: x [-h]\\n\\nbody'))", + ], + cwd=tmp_path, + capture_output=True, + text=True, + env={ + **os.environ, + "PYTHONPATH": str(tmp_path), + "PYTHONDONTWRITEBYTECODE": "1", + }, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "usage: x [-h]" + + +# ======================================================================================= +# Normalisation +# ======================================================================================= + + +class TestNormalisation: + """The three normalisations, and the line each one refuses to cross.""" + + @pytest.mark.parametrize( + "text,expected", + [ + ("a\r\nb", "a\nb"), + ("a\rb", "a\nb"), + ("a\nb", "a\nb"), + ("", ""), + ], + ) + def test_newlines_normalise_for_the_windows_runner(self, text, expected): + assert testing.normalise_text(text) == expected + + def test_a_crlf_golden_asserts_clean_against_lf_output(self): + """The Windows decision (option A), end to end at the comparator.""" + recorded = { + "tier": 1, + "returncode": 0, + "stdout": "one\r\ntwo\r\n", + "stderr": "", + "usage": "", + } + fresh = dict(recorded, stdout="one\ntwo\n") + assert testing.compare_case(recorded, fresh) == "" + + def test_usage_is_collapsed_so_it_is_columns_independent(self): + narrow = "usage: prog\n [--alpha]\n [--beta] x\n\nbody" + wide = "usage: prog [--alpha] [--beta] x\n\nbody" + assert testing.normalise_usage(narrow) == testing.normalise_usage(wide) + + def test_usage_stops_at_the_first_blank_line(self): + text = "usage: prog [-h]\n\npositional arguments:\n usage: not this\n" + assert testing.normalise_usage(text) == "usage: prog [-h]" + + def test_addresses_are_scrubbed_because_a_heap_pointer_is_not_a_behaviour(self): + assert ( + testing.scrub_addresses("") + == "" + ) + + def test_scrubbing_leaves_ordinary_text_alone(self): + assert testing.scrub_addresses("exit code 0x0 is not a thing") == ( + "exit code 0x0 is not a thing" + ) + + +# ======================================================================================= +# Cases: both spellings, because shlex is POSIX-only +# ======================================================================================= + + +class TestReadCases: + """`read_cases` parses a shlex line AND a JSON list -- issue #14's Windows path.""" + + def test_both_spellings_produce_the_same_argv(self, tmp_path): + path = tmp_path / "cases.txt" + path.write_text( + textwrap.dedent( + """\ + # a comment, and a blank line follow + + quickstart . --ignore + ["quickstart", ".", "--ignore"] + ["with a space", "and \\"quotes\\""] + """ + ), + encoding="utf-8", + ) + cases = testing.read_cases(path) + assert cases[0] == cases[1] == ["quickstart", ".", "--ignore"] + assert cases[2] == ["with a space", 'and "quotes"'] + + def test_comments_and_blank_lines_are_skipped(self, tmp_path): + path = tmp_path / "cases.txt" + path.write_text("# nothing\n\n \nreal\n", encoding="utf-8") + assert testing.read_cases(path) == [["real"]] + + +# ======================================================================================= +# The round trip: characterize a real CLI, mutate it, and assert replay notices +# ======================================================================================= + +TOY_CLI = '''\ +"""A toy argparse CLI, so the round trip runs against a real console script.""" +import argparse, sys + + +def main(argv=None): + parser = argparse.ArgumentParser(prog="toy", description="A toy.") + parser.add_argument("name") + parser.add_argument("--loudly", action="store_true") + args = parser.parse_args(argv) + print(f"HELLO {args.name}" if args.loudly else f"hello {args.name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +''' + +TOY_CASES = [["world"], ["world", "--loudly"], ["--help"], []] + + +@pytest.fixture +def toy(tmp_path): + """A real console script on disk, plus the command that runs it.""" + path = tmp_path / "toy.py" + path.write_text(TOY_CLI, encoding="utf-8") + return path, [P, str(path)] + + +class TestRoundTrip: + """characterize -> replay -> mutate -> replay fails. The harness's own falsifiability.""" + + def test_characterize_records_all_three_tiers(self, toy): + _, prog = toy + golden = testing.characterize(prog, TOY_CASES) + assert golden["cw_golden"] == testing.GOLDEN_VERSION + assert [case["argv"] for case in golden["cases"]] == TOY_CASES + greeting = golden["cases"][0] + assert greeting["returncode"] == 0 + assert greeting["stdout"] == "hello world\n" + assert greeting["tier"] == 1 + assert golden["cases"][2]["tier"] == 3, "--help is tier 3" + assert golden["cases"][3]["usage"].startswith("usage: toy") + + def test_replay_of_an_unchanged_cli_is_identical(self, toy): + _, prog = toy + golden = testing.characterize(prog, TOY_CASES) + assert all(r["status"] == "identical" for r in testing.replay(golden)) + testing.assert_replay(golden) # does not raise + + def test_replay_notices_a_mutation_and_says_what_changed(self, toy): + path, prog = toy + golden = testing.characterize(prog, TOY_CASES) + path.write_text(TOY_CLI.replace("hello {", "howdy {"), encoding="utf-8") + with pytest.raises(AssertionError) as caught: + testing.assert_replay(golden) + message = str(caught.value) + assert "1 of 4 cases changed behaviour" in message + assert "- hello world" in message and "+ howdy world" in message + + def test_replay_notices_a_lost_flag(self, toy): + path, prog = toy + golden = testing.characterize(prog, TOY_CASES) + path.write_text( + TOY_CLI.replace('parser.add_argument("--loudly", action="store_true")', ""), + encoding="utf-8", + ) + statuses = [r["status"] for r in testing.replay(golden)] + assert statuses.count("differs") >= 2, statuses + + def test_a_tier_three_help_change_is_NOT_a_failure(self, toy): + """The whole point of tier 3: help text moves, and the gate does not go red.""" + path, prog = toy + golden = testing.characterize(prog, TOY_CASES) + path.write_text( + TOY_CLI.replace('description="A toy."', 'description="Toy!"'), "utf-8" + ) + assert all(r["status"] == "identical" for r in testing.replay(golden)) + + def test_but_diff_help_reports_it_advisorily(self, toy): + path, prog = toy + golden = testing.characterize(prog, TOY_CASES) + path.write_text( + TOY_CLI.replace('description="A toy."', 'description="Toy!"'), "utf-8" + ) + advisory = testing.diff_help(golden) + assert "-A toy." in advisory and "+Toy!" in advisory + + def test_diff_help_is_empty_when_nothing_moved(self, toy): + _, prog = toy + assert testing.diff_help(testing.characterize(prog, TOY_CASES)) == "" + + +class TestExpectDiff: + """`expect_diff` marks an intended break -- in both directions.""" + + def test_an_intended_break_is_not_a_failure(self, toy): + path, prog = toy + golden = testing.characterize(prog, TOY_CASES) + path.write_text(TOY_CLI.replace("hello {", "howdy {"), encoding="utf-8") + testing.assert_replay(golden, expect_diff=[["world"]]) + + def test_an_intended_break_that_did_not_happen_IS_a_failure(self, toy): + """A migration note claiming a break that did not occur is also wrong.""" + _, prog = toy + golden = testing.characterize(prog, TOY_CASES) + with pytest.raises(AssertionError, match="unchanged"): + testing.assert_replay(golden, expect_diff=[["world"]]) + + def test_expect_diff_accepts_a_shlex_string_too(self, toy): + path, prog = toy + golden = testing.characterize(prog, TOY_CASES) + path.write_text(TOY_CLI.replace("hello {", "howdy {"), encoding="utf-8") + testing.assert_replay(golden, expect_diff=["world"]) + + +class TestGoldenFile: + """The committed artefact: stable, versioned, and self-describing.""" + + def test_written_goldens_are_stable_under_re_recording(self, toy, tmp_path): + _, prog = toy + first = tmp_path / "a.json" + second = tmp_path / "b.json" + testing.characterize(prog, TOY_CASES, out_path=first) + testing.characterize(prog, TOY_CASES, out_path=second) + assert first.read_text() == second.read_text() + + def test_a_golden_is_sorted_and_newline_terminated(self, toy, tmp_path): + _, prog = toy + path = tmp_path / "g.json" + testing.characterize(prog, TOY_CASES, out_path=path) + text = path.read_text() + assert text.endswith("\n") + assert json.loads(text) == json.loads(text) # parses + assert text.index('"cases"') < text.index('"prog"'), "keys are sorted" + + def test_loading_something_that_is_not_a_golden_says_so(self): + with pytest.raises(ValueError, match="not a cw golden"): + testing.load_golden({"cases": []}) + + def test_a_golden_from_a_future_format_says_to_re_record(self): + with pytest.raises(ValueError, match="Re-record"): + testing.load_golden({"cw_golden": 99, "cases": []}) + + def test_a_timeout_is_reported_rather_than_raised(self, tmp_path): + slow = tmp_path / "slow.py" + slow.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") + golden = testing.characterize([P, str(slow)], [[]], timeout=0.4) + assert golden["cases"][0]["returncode"] is None + assert "timed out" in golden["cases"][0]["stderr"] + + +class TestPinnedEnv: + """Recording pins the environment; it must also put it back.""" + + def test_columns_is_pinned_inside_and_restored_outside(self): + before = os.environ.get("COLUMNS") + with testing.pinned_env(): + assert os.environ["COLUMNS"] == testing.DFLT_COLUMNS + assert os.environ.get("COLUMNS") == before + + def test_argcomplete_is_removed_so_it_cannot_hijack_a_recording(self): + os.environ["_ARGCOMPLETE"] = "1" + try: + with testing.pinned_env(): + assert "_ARGCOMPLETE" not in os.environ + assert os.environ["_ARGCOMPLETE"] == "1" + finally: + os.environ.pop("_ARGCOMPLETE", None) + + +class TestExitStatus: + """`capture` reproduces what the interpreter does with a `SystemExit`.""" + + @pytest.mark.parametrize( + "call,code,err", + [ + (lambda: None, 0, ""), + (lambda: 0, 0, ""), + (lambda: 3, 3, ""), + (lambda: (_ for _ in ()).throw(SystemExit()), 0, ""), + (lambda: (_ for _ in ()).throw(SystemExit(4)), 4, ""), + (lambda: (_ for _ in ()).throw(SystemExit("bye")), 1, "bye\n"), + ], + ) + def test_the_six_shapes_an_exit_can_take(self, call, code, err): + outcome = testing.capture(call) + assert (outcome["returncode"], outcome["stderr"]) == (code, err) + + def test_a_crash_is_recorded_without_a_machine_specific_traceback(self): + outcome = testing.capture(lambda: {}["nope"]) + assert outcome["returncode"] == 1 + assert outcome["stderr"] == "KeyError: 'nope'\n" + assert 'File "' not in outcome["stderr"] + + +# ======================================================================================= +# The gate itself +# ======================================================================================= + + +class TestParity: + """`python -m cw.testing parity` -- the definition of v1.""" + + def test_it_passes(self): + out = io.StringIO() + assert testing.parity(out=out) == 0 + assert out.getvalue().strip().endswith(": identical") + + def test_it_reports_real_derived_numbers(self): + from cw.tests import fixtures + + out = io.StringIO() + testing.parity(out=out) + assert ( + f"{len(fixtures.SHAPES)} shapes / {fixtures.case_count()} cases" + in out.getvalue() + ) + + def test_an_empty_goldens_directory_fails_rather_than_passing_vacuously( + self, tmp_path + ): + out = io.StringIO() + assert testing.parity(tmp_path, out=out) == 1 + assert "no goldens" in out.getvalue() + + def test_a_tampered_golden_makes_it_fail_with_a_readable_diff(self, tmp_path): + """The gate must be able to go red, and to say why in words.""" + source = os.path.join(testing.DFLT_GOLDENS_DIR, "contract.json") + golden = json.load(open(source, encoding="utf-8")) + for case in golden["cases"]: + if case["argv"] == ["returns-dict"]: + case["stdout"] = "a\nb\n" # as if a dict were iterated + testing.write_golden(golden, tmp_path / "contract.json") + out = io.StringIO() + assert testing.parity(tmp_path, out=out) == 1 + report = out.getvalue() + assert "1 DIFFER" in report + assert "$ returns-dict" in report + assert "{'a': 1, 'b': 2}" in report + + def test_it_runs_as_a_subprocess_and_exits_zero(self): + result = subprocess.run( + [P, "-m", "cw.testing", "parity"], capture_output=True, text=True + ) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip().endswith(": identical") + + def test_the_gate_pulls_no_argh(self): + """The CI claim, asserted by the process rather than by convention.""" + result = subprocess.run( + [ + P, + "-c", + "import sys; from cw.testing import parity; import io; " + "parity(out=io.StringIO()); " + "print(sorted(m for m in sys.modules if m.split('.')[0] == 'argh'))", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "[]" + + +class TestCommandLine: + """`python -m cw.testing` -- built with argparse, because it must not import cw.""" + + def test_no_command_prints_usage_and_exits_zero(self): + result = subprocess.run([P, "-m", "cw.testing"], capture_output=True, text=True) + assert result.returncode == 0 + assert result.stdout.startswith("usage: python -m cw.testing") + + def test_characterize_and_replay_from_the_command_line(self, toy, tmp_path): + path, prog = toy + cases = tmp_path / "cases.txt" + cases.write_text('["world"]\n["world", "--loudly"]\n', encoding="utf-8") + golden = tmp_path / "toy.json" + record = subprocess.run( + [ + P, + "-m", + "cw.testing", + "characterize", + f"{P} {path}", + "--cases", + str(cases), + "-o", + str(golden), + ], + capture_output=True, + text=True, + ) + assert record.returncode == 0, record.stderr + assert "recorded 2 cases" in record.stdout + + again = subprocess.run( + [P, "-m", "cw.testing", "replay", str(golden)], + capture_output=True, + text=True, + ) + assert again.returncode == 0, again.stdout + assert "2/2 identical" in again.stdout + + path.write_text(TOY_CLI.replace("hello {", "howdy {"), encoding="utf-8") + broken = subprocess.run( + [P, "-m", "cw.testing", "replay", str(golden)], + capture_output=True, + text=True, + ) + assert broken.returncode == 1 + assert "1/2 identical" in broken.stdout + + +class TestCommandLineInProcess: + """`main()` called directly, so the CLI's own branches are covered rather than shelled. + + `TestCommandLine` runs the same commands as subprocesses, which is the honest end-to-end + check but leaves `main` and `_cli` invisible to coverage. Both matter: a subprocess proves + the entry point exists, and this proves each branch of it does something. + """ + + def test_parity_verbose_names_every_shape(self, capsys): + assert testing.main(["parity", "-v"]) == 0 + report = capsys.readouterr().out + from cw.tests import fixtures + + for name in fixtures.SHAPES: + assert f" {name}: " in report + + def test_parity_over_an_explicit_goldens_directory(self, capsys): + assert testing.main(["parity", testing.DFLT_GOLDENS_DIR]) == 0 + assert capsys.readouterr().out.strip().endswith(": identical") + + def test_no_command_prints_usage(self, capsys): + assert testing.main([]) == 0 + assert capsys.readouterr().out.startswith("usage: python -m cw.testing") + + def test_characterize_replay_and_diff_help(self, toy, tmp_path, capsys): + path, prog = toy + cases = tmp_path / "cases.txt" + cases.write_text('["world"]\n["--help"]\n', encoding="utf-8") + golden = tmp_path / "toy.json" + + assert ( + testing.main( + [ + "characterize", + f"{P} {path}", + "--cases", + str(cases), + "-o", + str(golden), + "--note", + "before the migration", + ] + ) + == 0 + ) + assert "recorded 2 cases" in capsys.readouterr().out + assert testing.load_golden(golden)["note"] == "before the migration" + + assert testing.main(["replay", str(golden)]) == 0 + assert "2/2 identical" in capsys.readouterr().out + + assert testing.main(["diff-help", str(golden)]) == 0 + assert "no --help changes" in capsys.readouterr().out + + path.write_text(TOY_CLI.replace("hello {", "howdy {"), encoding="utf-8") + assert testing.main(["replay", str(golden)]) == 1 + assert "1/2 identical" in capsys.readouterr().out + + assert testing.main(["replay", str(golden), "--expect-diff", "world"]) == 0 + + def test_characterize_takes_a_cases_FILE_as_well_as_a_list(self, toy, tmp_path): + """`characterize(cases=)` -- the spelling the command line uses.""" + _, prog = toy + cases = tmp_path / "cases.txt" + cases.write_text('["world"]\n', encoding="utf-8") + golden = testing.characterize(prog, cases) + assert [case["argv"] for case in golden["cases"]] == [["world"]] + + def test_a_stream_the_command_already_closed_does_not_break_the_capture(self): + """`_flush` forgives a closed stream, because a CLI is allowed to close its own.""" + + def closes_its_own_stdout(): + sys.stdout.close() + return 0 + + assert testing.capture(closes_its_own_stdout)["returncode"] == 0 From f94ed120c4c09d0c2d3ae54758040ca3c408efb2 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:07:35 +0200 Subject: [PATCH 5/7] ADRs, README rewrite, and the release metadata for v1 Six ADRs in docs/adr/ (the fleet's convention -- openloops, paces, comparanda, rubricator all use it), a README that teaches cw instead of argh, and a pyproject that is actually publishable. refs #8 #9 #10 #11 #12 #13 docs/adr/0001..0006 -- the seam table, the ingress stash, the merge ladder, the grammar errata, the release/rollback policy, and the v1 cut list. Audited rather than trusted: every claim was re-checked against the code and the fleet, and three were wrong. * The argcomplete census. The spec says 7 repos, an earlier draft said 8. Counted over $PP it is **10 marker files across 10 repos**, every one an argh consumer. ADR-0001 now lists them. * Three transcripts in ADR-0003/0004 had never been run -- one showed its outputs as trailing comments and referenced an undefined `h2`. The *facts* were right; the blocks were not runnable. Rewritten as real transcripts. * ADR-0005's rollback drill claims "the transcript below is real". It is: re-executed end to end (two wheels, a throwaway consumer pinned `cw>=0.1,<0.2`, a fresh venv) and it reproduces byte for byte, including the GrammarError text and `36 DIFFER`. Every example in the docs now runs, and tests/test_docs_examples.py keeps it that way -- 65 examples over 8 files, mutation-checked to prove it fails when an expected output drifts. Three things that would have broken the release, found by running the environments CI actually uses rather than only the dev one: * **`docs/*` is gitignored** (wads template, for Sphinx output), so every ADR here would have been silently dropped on commit. Negated with `!docs/adr/`. * **`pip install -e '.[test]' && pytest` failed at collection**: tests/test_fleet_shapes.py imports argh at module scope, and argh is a *dev* extra. The argh-free capture helper moves to tests/capture.py, the one genuine differential skips itself, and the ten cw-only assertions in that file now run in CI. tests/test_grammar.py had the same problem via the shared corpus; test_corpus_coverage.py used `tomllib`, which is 3.11+, on a matrix that includes 3.10. * **The parity gate was red on 3.10, 3.11 and 3.13 with a correct cw.** argparse quoted its `invalid choice` items until 3.11 and stopped in 3.12; 3.13 rewrapped the usage block's trailing `...`. Those are CPython's renderings, identical for argh and cw on any one interpreter, so a golden recorded on 3.12 was asserting the recording machine's version. `cw.testing.canonical_argparse_text` neutralises exactly those two, on both sides. The nine-mutation battery still goes 9/9 red with the *same* counts on 3.10 and on 3.12, so nothing real was forgiven. pyproject: PEP 639 `license = "MIT"` + `license-files` (was the deprecated `[project.license] text` table), classifiers (there were none), keywords, author, Issues URL. LICENSE still said `Copyright (c) [year] [fullname]` -- for a package whose whole pitch is replacing an LGPL one. `cw[test]` gains `cw[resource]`: i2 is the house's own package, not the one cw exists to replace, and without it CI silently skipped cw.resolution's tests and doctests. Verified (real output, /Users/thorwhalen/.pyenv/versions/p12/bin/python3): dev (p12) 805 passed, 2 skipped CI 3.12 537 passed, 10 skipped (cw[test], no argh) CI 3.10 536 passed, 11 skipped (cw[test], no argh) parity identical on 3.10 / 3.11 / 3.12 / 3.13 clean venv cw + pip only, gate passes from an installed cw wheel License-Expression: MIT, no unconditional Requires-Dist ruff check + format clean Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr --- .gitignore | 3 + LICENSE | 2 +- README.md | 524 +++++++++++++------ cw/cli.py | 8 +- cw/testing.py | 54 +- docs/adr/0001-the-v1-seam-table.md | 164 ++++++ docs/adr/0002-the-ingress-stash.md | 208 ++++++++ docs/adr/0003-the-merge-ladder.md | 189 +++++++ docs/adr/0004-grammar-errata.md | 175 +++++++ docs/adr/0005-release-and-rollback-policy.md | 239 +++++++++ docs/adr/0006-the-v1-cut-list.md | 166 ++++++ docs/adr/README.md | 17 + pyproject.toml | 48 +- tests/__init__.py | 6 + tests/argh_parity/__init__.py | 10 + tests/argh_parity/test_cli_parity.py | 15 +- tests/capture.py | 36 ++ tests/test_cli.py | 4 +- tests/test_convention.py | 2 +- tests/test_corpus_coverage.py | 6 +- tests/test_docs_examples.py | 97 ++++ tests/test_fleet_shapes.py | 29 +- tests/test_grammar.py | 4 + tests/test_testing.py | 65 +++ 24 files changed, 1877 insertions(+), 194 deletions(-) create mode 100644 docs/adr/0001-the-v1-seam-table.md create mode 100644 docs/adr/0002-the-ingress-stash.md create mode 100644 docs/adr/0003-the-merge-ladder.md create mode 100644 docs/adr/0004-grammar-errata.md create mode 100644 docs/adr/0005-release-and-rollback-policy.md create mode 100644 docs/adr/0006-the-v1-cut-list.md create mode 100644 docs/adr/README.md create mode 100644 tests/capture.py create mode 100644 tests/test_docs_examples.py diff --git a/.gitignore b/.gitignore index 76eb2d8..0a51658 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ instance/ # Sphinx documentation docs/_build/ docs/* +# ...but the architecture decision records are source, not build output. Without this +# negation `docs/*` swallows them silently -- they simply never get committed. +!docs/adr/ # PyBuilder target/ diff --git a/LICENSE b/LICENSE index 8aa2645..21516f8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) [year] [fullname] +Copyright (c) 2026 Thor Whalen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 544375f..053d97e 100644 --- a/README.md +++ b/README.md @@ -1,227 +1,433 @@ -# CW: Command-line Wrapper Utilities +# cw — your functions, as a command-line tool -CW is a Python package that provides utilities for wrapping functions to work seamlessly with command-line interfaces (CLIs). It specializes in resolving string-based function specifications into actual callable functions, making it easy to pass complex objects like functions as command-line parameters. +```python +import cw + +def greet(name, *, loudly=False): + """Say hello to someone.""" + return f'HELLO {name}' if loudly else f'hello {name}' + +raise SystemExit(cw.dispatch(greet)) # that is the whole CLI +``` + +```console +$ python greet.py world --loudly +HELLO world +``` -## Why CW? +`pip install cw` — no dependencies, MIT, and `import cw` costs stdlib only. -When building command-line interfaces with great "dispatch to CLI" tools like -`argh`, `click`, `docopt`, or `typer`, all parameter values are fundamentally strings. -This creates a challenge when your Python functions need to accept complex objects like other functions as parameters. +--- -Consider this example using `argh`: +## What it does + +You hand `cw.dispatch` a function, a list of functions, a dict, or a module. It reads the +signatures, builds an `argparse` parser, calls the function you asked for, prints what it +returned, and returns an exit code. Nothing to decorate, nothing to declare. + +```console +$ python greet.py --help +usage: greet.py [-h] [-l] name + +Say hello to someone. + +positional arguments: + name - + +options: + -h, --help show this help message and exit + -l, --loudly False + +$ python greet.py +usage: greet.py [-h] [-l] name +greet.py: error: the following arguments are required: name +$ echo $? +2 +``` + +The parser `cw` builds is a **plain `argparse.ArgumentParser`**, never a subclass. That is +deliberate and load-bearing: `argcomplete.autocomplete()` is argparse-typed at its +signature, so shell completion works on a cw CLI with the same +`# PYTHON_ARGCOMPLETE_OK` marker and no adapter. + +### More than one command + +Pass anything with several functions in it. A **callable value is a command**; a **mapping +or iterable value is a group**: ```python -import argh -from cw.resolution import resource_inputs, parse_ast_spec - -def process_data(data, transform_func, multiplier=1): - """Process data using a transformation function.""" - transformed = transform_func(data) - return transformed * multiplier - -# Without CW: This won't work from CLI because transform_func is a string -@argh.dispatch_command -def cli_process(data, transform_func, multiplier=1): - return process_data(data, transform_func, multiplier) # ERROR: transform_func is a string! - -# With CW: This works seamlessly -function_store = { - 'double': lambda x: x * 2, - 'square': lambda x: x ** 2, - 'upper': str.upper -} +import cw + +def add(a: int, b: int): + """Add two numbers.""" + return a + b -wrapped_process = resource_inputs( - process_data, - resource={ - 'transform_func': { - 'func_key_and_kwargs': parse_ast_spec, - 'get_func': function_store.get - } - } -) +def ls(path='.', *, long=False): + """List a directory.""" + return [f'{path}/one', f'{path}/two'] -@argh.dispatch_command -def cli_process_working(data, transform_func, multiplier=1): - return wrapped_process(data, transform_func, multiplier) +COMMANDS = {'add': add, 'list': ls, 'git-ops': {'add': add}} +raise SystemExit(cw.dispatch(COMMANDS, prog='tool')) ``` -Now you can call from the command line: -```bash -python script.py "hello" "upper()" --multiplier 3 -# Results in: "HELLOHELLOHELLO" +```console +$ tool --help +usage: tool [-h] {add,list,git-ops} ... + +positional arguments: + {add,list,git-ops} + add Add two numbers. + list List a directory. + git-ops + +options: + -h, --help show this help message and exit -python script.py 5 "double()" --multiplier 2 -# Results in: 20 (5 * 2 * 2) +$ tool add 2 3 +5 +$ tool list -p /tmp +/tmp/one +/tmp/two ``` -## Core Components +Six forms of `obj`, each decided by the **value**, never by a string DSL: -### Function Resolution (`cw.resolution`) +| `obj` | becomes | +|---|---| +| any callable | **one command**, with no command word at all | +| `[f, g]` — any iterable | commands named from each `__name__` | +| `{'name': f}` — a mapping | commands named **by the key** | +| `{'grp': }` | a **group** (one level deep, argh's limit) | +| a module, or any object | its public callable attributes; `__all__`, if present, is the name list | +| `'pkg.mod:name'` — a string | **always exactly one command**, imported lazily | -The resolution module provides utilities to convert string specifications into callable functions: +A name from a key or `__all__` beats `__name__`, and is then hyphenated: +`{'parse_pth_paths': f}` gives you `parse-pth-paths`. -- **`resolve_to_function`**: Main resolution function supporting multiple spec formats -- **`resolve_func_from_dot_path`**: Import and resolve functions from dot notation paths -- **`parse_json_spec`**: Parse JSON-formatted function specifications -- **`parse_ast_spec`**: Parse AST-formatted function call specifications +### What a command's return value does -### Resource Inputs Decorator +`None` prints nothing. A list, a tuple or a **generator** prints one line per item — lazily, +flushing as it goes, so a long-running command streams. Anything else prints as one line, +`dict`s included. `0`, `False` and `''` do print. -The `resource_inputs` decorator wraps functions to automatically resolve specified string parameters into actual objects: +```python +>>> import cw +>>> def lines(): +... return ['a', 'b'] +>>> cw.dispatch(lines, []) +a +b +0 +``` + +Raise `cw.CommandError` for an expected failure: one line to stderr, no traceback, and an +exit code you choose. Any other exception keeps its traceback, because a bug deserves one. + +## `config` — this call's particulars + +`config` is a plain dict, **shaped exactly like `obj`** and keyed the way you type it on the +command line. Its leaves are `add_argument` keyword arguments: ```python -from cw.resolution import resource_inputs, parse_ast_spec +CONFIG = { + 'path': {'help': 'the directory to list', 'metavar': 'DIR'}, + 'long': {'flags': ['-l', '--long'], 'help': 'one line per entry'}, +} +cw.dispatch(ls, config=CONFIG, prog='ls') +``` -def func(apple, banana, carrot): - return f"{apple=}, {banana=}, {carrot=}" +```console +$ ls --help +usage: ls [-h] [-p DIR] [-l] -function_store = {'a': lambda: 1, 'b': lambda: 2} +List a directory. -wrapped_func = resource_inputs( - func, - resource=dict( - apple=None, # Use default resolve_to_function - carrot=dict( - func_key_and_kwargs=parse_ast_spec, - get_func=function_store.get - ) - ) -) +options: + -h, --help show this help message and exit + -p DIR, --path DIR the directory to list (default: '.') + -l, --long one line per entry (default: False) ``` -## Examples +For several commands it nests the same way `obj` does — +`{'git-ops': {'add': {'a': {'help': '...'}}}}` — and **a key that names no command, group or +parameter is a startup error**, listing the names that do exist. That is on purpose: a +mis-keyed config entry that silently does nothing is the failure mode this rule exists to +kill. + +Two leaf values are not `add_argument` kwargs: -### Basic Function Resolution +- `cw.HIDE` removes a parameter from the command line entirely, leaving it to its own + default. This is how you keep a dependency-injection parameter out of the CLI. +- `{'codec': callable}` decodes a parsed token *after* parsing — the place for a `str -> object` + conversion that `argparse`'s `type=` would get wrong, because argparse applies `type=` to + defaults and to `const` too. ```python ->>> from cw.resolution import resolve_to_function, parse_json_spec, parse_ast_spec +>>> def serve(host='0.0.0.0', port=8080, pool=None): +... return f'{host}:{port}' +>>> parser = cw.mk_parser(serve, config={'pool': cw.HIDE}) +>>> [a.dest for a in parser._actions][1:] +['host', 'port'] +``` -# Direct callable (returned as-is) ->>> resolve_to_function(len) # doctest: +ELLIPSIS - +## `convention` — what the defaults ARE -# Simple string (dot path) ->>> length_func = resolve_to_function('builtins.len') ->>> length_func([1, 2, 3]) -3 +`config` is per call; `convention` is per **context** — a repo, a house style, the fleet. +It is a frozen dataclass of nine fields, and two values ship. -# JSON format ->>> json_spec = '{"func": "len", "params": {}}' ->>> func = resolve_to_function(json_spec, parse_json_spec) ->>> func([1, 2, 3]) -3 +**`cw.ARGH` is the default, and it reproduces `argh` 0.31.3 bit-for-bit** — footguns +included. That is the point: a repo swaps its dispatcher for cw and its `--help` does not +move. Concretely, under `ARGH`: -# Dot path format ->>> func = resolve_to_function("builtins.len") ->>> func([1, 2, 3]) -3 +- a parameter **with a default becomes an option**, one without becomes a positional; +- `bool=True` becomes `store_false` (so `--loudly` on `loudly=True` turns it *off*); +- an option's help column is `repr(default)` unless you give it one; +- short flags come from the first character, and **if two parameters share one, neither gets + a short flag** — `-h` is always lost to `--help`; +- a `dict` return value is **not** iterated; a `map` object prints as ``; +- `**kwargs` is silently dropped from the parser. + +**`cw.MODERN` is the same grammar with the sharp edges filed off** — and it is one keyword: + +```python +cw.dispatch(COMMANDS, convention=cw.MODERN) ``` -### Advanced Function Resolution with AST +It makes a parameter positional only when the signature says so (`*`-keyword-only becomes an +option), resolves type annotations even when you have overridden something, hyphenates group +names, iterates anything iterable that is not a `str`/`bytes`/`Mapping`, and unwraps +`Optional[X]`, `Enum` and `pathlib.Path`. ```python ->>> # AST format ->>> ast_func = resolve_to_function('str.upper()', parse_ast_spec) ->>> ast_func('hello') -'HELLO' +>>> import cw +>>> def counted(): +... return map(str, range(2)) +>>> cw.dispatch(counted, []) # ARGH: a map is not a list + +0 +>>> cw.dispatch(counted, [], convention=cw.MODERN) # MODERN: anything iterable +0 +1 +0 ``` -### Resource Inputs in Action +Every improvement cw has ships as a named convention value that **defaults off**. There is +no third value and no `Convention(...)` you are expected to build; if you want one, +`dataclasses.replace(cw.ARGH, short_flags=False)` is a `Convention`. + +**The house idiom is `functools.partial`**, not a cw-specific binder: ```python ->>> def func(apple, banana, carrot): -... return f"{apple=}, {banana=}, {carrot=}" ->>> ->>> from cw.resolution import parse_ast_spec ->>> function_store = {'a': lambda: 1, 'b': lambda: 2} ->>> ->>> wrapped_func = resource_inputs( -... func, -... resource=dict( -... apple=None, # Use default resolve_to_function -... carrot=dict( -... func_key_and_kwargs=parse_ast_spec, -... get_func=function_store.get -... ) -... ) -... ) - -# Now apple will be resolved via resolve_to_function -# carrot will be resolved via resolve_to_function with custom params -# banana remains unchanged (passed through as-is) - ->>> # apple='builtins.len' -> resolve_to_function('builtins.len') -> len function ->>> # banana='test' -> unchanged (no resource specified) ->>> # carrot='a()' -> parsed as AST, resolved via function_store ->>> result = wrapped_func('builtins.len', 'test', 'a()') ->>> 'apple=' in result -True ->>> "banana='test'" in result -True ->>> 'carrot=>> parser = cw.mk_parser(COMMANDS, prog='tool') +>>> type(parser) is __import__('argparse').ArgumentParser True +>>> cw.run(parser, ['add', '2', '3']) +5 +0 ``` -## Parser Functions +The convention travels *with* the parser, so `convention=cw.MODERN` stays one act even +when build and run are two calls. (How that works — one reserved `set_defaults` key on a +parser that is deliberately not a subclass — is +[ADR-0002](docs/adr/0002-the-ingress-stash.md).) + +**Bind a function to a parser you built yourself:** + +```python +>>> import argparse +>>> parser = argparse.ArgumentParser(prog='count') +>>> parser.add_argument('--verbose', action='store_true') and None +>>> def tally(word, *, times=1): +... return [word] * times +>>> _ = cw.set_default_command(parser, tally) +>>> cw.run(parser, ['hi', '-t', '2']) +hi +hi +0 +``` -### Dot Path Parser +**Capture the output.** `out=` and `err=` are plain parameters resolved at *call* time, so a +test can pass a `StringIO` — something an `argh` CLI cannot do, because argh binds +`output_file=sys.stdout` in a signature default: ```python ->>> from cw.resolution import parse_spec_with_dot_path ->>> parse_spec_with_dot_path('os.path.join') -('os.path.join', {}) +>>> import io +>>> out = io.StringIO() +>>> cw.dispatch(lines, [], out=out) +0 +>>> out.getvalue() +'a\nb\n' +``` + +They capture argparse's own `--help`, `usage:` and `error:` output. They do **not** capture a +command body's own `print()`, which goes where the process's `print` goes — as it does under +argh. + +**Skip the CLI entirely.** `standalone=False` makes the same call an ordinary function call: +nothing printed, nothing caught, the function's own return value: ->>> parse_spec_with_dot_path('len') -('len', {}) +```python +>>> cw.dispatch(greet, ['world', '--loudly'], standalone=False) +'HELLO world' ``` -### JSON Parser +**Change how results are printed** — `egress=` is one keyword: ```python ->>> from cw.resolution import parse_json_spec ->>> parse_json_spec('{"func": "len", "params": {}}') -('len', {}) +>>> cw.dispatch(lambda: {'a': 1}, [], egress=cw.json_egress) +{ + "a": 1 +} +0 +``` + +`cw.argh_egress` (the default), `cw.iterable_egress` (MODERN's) and `cw.json_egress` ship; +an egress is any `(result, *, out, err) -> int`. + +**Change how types are inferred** — `decode=` is one keyword, taking +`(inspect.Parameter, hint) -> add_argument kwargs | callable | None`. `cw.argh_decode` and +`cw.modern_decode` ship. + +Those three — `decode=`, `egress=`, `convention=` — are cw's only seams, and the list of +things that are deliberately **not** seams is as binding as the list that are. Both are in +[ADR-0001](docs/adr/0001-the-v1-seam-table.md). + +**Shell completion:** + +```bash +pip install 'cw[completion]' +``` + +then put `# PYTHON_ARGCOMPLETE_OK` at the top of your console script. `cw.run` calls +`argcomplete` for you when it is installed, and does nothing when it is not. + +## Migrating from `argh` + +`cw` replaces `argh` (LGPL-3.0-or-later) with an MIT package that reproduces its grammar. +There are two steps and you can stop after the first for as long as you like. + +**Step 1 — one line.** `cw.compat` implements argh's eleven measured names: ->>> parse_json_spec('{"func": "str.replace", "params": {"old": "a", "new": "b"}}') -('str.replace', {'old': 'a', 'new': 'b'}) +```diff +-import argh ++from cw import compat as argh ``` -### AST Parser +Everything warns once, on first use; `CW_COMPAT_QUIET=1` silences it for a repo that has +decided to live here for a while. Declare the dependency as: + +```toml +dependencies = ["cw>=0.1,<0.2"] +``` + +**Before you do it, grep for `from argh import CommandError`.** A module that imported the +*name* rather than the module still imports argh after the one-line change, and cw will not +catch an exception class it has never heard of — `CommandError: boom` / exit 1 becomes an +unhandled traceback. The shim structurally cannot fix this one; it is the single +highest-value line on the checklist. + +**Step 2 — delete the compat import.** `dispatch_commands(funcs)` becomes +`cw.dispatch(funcs)`; `@argh.arg(...)` decorators become one `config` dict; `argh`'s +`__name__`-mutation trick for renaming a command becomes a mapping key. + +**Prove the migration did nothing.** `cw.testing` records a CLI's behaviour before the change +and replays it after: + +```bash +# on the old code -- this half imports no cw +python -m cw.testing characterize 'mytool' --cases ./cases.txt -o before.json +# on the new +python -m cw.testing replay before.json --prog 'mytool' +``` + +The recording half **imports no `cw`** — it is one file you can copy into a repo that will +never depend on cw, which is most of them. + +Two things to expect, both argh's rules that cw reproduces: + +- `config` keys are spelled the way the **command line** reads (`parse-pth-paths`), while + `obj` keys and `__all__` entries are Python identifiers. Adjacent dicts, two spellings. + A wrong key is a startup error, not a silent drop. +- Under `cw.ARGH`, one `config` entry disables annotation inference for the whole function + (above, and [ADR-0003](docs/adr/0003-the-merge-ladder.md)). + +## cw's own CLI + +```bash +python -m cw specs 'mypkg.cli:main' # what flags would this function get, and why? +python -m cw help 'mypkg.cli:main' # the --help cw would print for it +python -m cw parity # cw's own migration gate, 8 shapes / 133 cases +``` + +`specs` is the one that earns its place day to day — it answers *"why did that parameter not +get a short flag?"* without building, running or importing anybody's `__main__`. + +## Resolving a function from a string + +`cw.resolution` is older than the dispatcher and independent of it: it turns a string +specification into a callable, which is what lets a CLI accept *a function* as a parameter +value. ```python ->>> from cw.resolution import parse_ast_spec ->>> parse_ast_spec('len()') -('len', {}) +>>> from cw import resolve_to_function, parse_ast_spec +>>> resolve_to_function('builtins.len')([1, 2, 3]) +3 +>>> resolve_to_function('str.upper()', parse_ast_spec)('hello') +'HELLO' +``` ->>> parse_ast_spec('str.replace(old="a", new="b")') -('str.replace', {'old': 'a', 'new': 'b'}) +`parse_json_spec`, `parse_ast_spec` and `parse_spec_with_dot_path` are the three spec +grammars; `resource_inputs` wraps a function so that named parameters are resolved on the +way in. It is the one place cw touches a third-party package, and it is an optional extra: ->>> parse_ast_spec('range(start=0, stop=10)') -('range', {'start': 0, 'stop': 10}) +```bash +pip install 'cw[resource]' ``` -## Installation +## Install ```bash -pip install cw +pip install cw # the CLI. no dependencies. +pip install 'cw[completion]' # + argcomplete, for shell completion +pip install 'cw[resource]' # + i2, for cw.resource_inputs +pip install 'cw[dev]' # + pytest and argh, to run the differential test suite ``` -## Key Features +Python 3.10+. -- **CLI-First Design**: Built specifically for command-line interface needs -- **Multiple Resolution Formats**: Support for dot paths, JSON, and AST expressions -- **Flexible Resource Specifications**: Configure resolvers per parameter -- **Function Store Integration**: Use custom mappings for function resolution -- **Parameter Binding**: Automatic parameter binding with `functools.partial` -- **Type Safety**: Comprehensive validation and clear error messages +## Design notes -## Use Cases +Six decisions, with their evidence, in [`docs/adr/`](docs/adr/): -- **CLI Tools**: Convert string parameters to functions in command-line applications -- **Configuration Systems**: Resolve function references from config files -- **Plugin Systems**: Dynamically load and configure functions -- **Data Processing Pipelines**: Specify transformation functions as strings -- **API Endpoints**: Accept function specifications in REST APIs +| | | +|---|---| +| [ADR-0001](docs/adr/0001-the-v1-seam-table.md) | The three seams, and everything that is deliberately not one | +| [ADR-0002](docs/adr/0002-the-ingress-stash.md) | How a plain `ArgumentParser` carries its convention and ingress into `run` | +| [ADR-0003](docs/adr/0003-the-merge-ladder.md) | The four-tier merge ladder is argh's field-specific merge, not `dict.update` | +| [ADR-0004](docs/adr/0004-grammar-errata.md) | `group_kwargs`, mapping-key naming, MODERN's help column | +| [ADR-0005](docs/adr/0005-release-and-rollback-policy.md) | Release, pinning and rollback — `cw.ARGH` is frozen once published | +| [ADR-0006](docs/adr/0006-the-v1-cut-list.md) | What v1 does not ship, and where each cut comes back | -CW bridges the gap between the string-based world of command-line interfaces and the rich object model of Python, making it easy to build powerful, flexible CLI tools. \ No newline at end of file +Two properties worth stating because they are easy to lose and hard to get back: +`cw.mk_parser` returns a **plain** `ArgumentParser`, and **`import cw` pulls stdlib only** — +both are asserted by tests, not by intention. diff --git a/cw/cli.py b/cw/cli.py index 367d736..139654f 100644 --- a/cw/cli.py +++ b/cw/cli.py @@ -284,10 +284,12 @@ def _check_config_keys(tree: Mapping, config: Mapping, *, what: str) -> None: unknown = [key for key in config if key not in tree] if unknown: known = ", ".join(tree) or "(none)" + plural = len(unknown) > 1 raise GrammarError( - f"config key{'s' if len(unknown) > 1 else ''} " - f"{', '.join(repr(key) for key in unknown)} match no {what}. " - f"The {what}s are: {known}. Note that names are hyphenated by the " + f"config key{'s' if plural else ''} " + f"{', '.join(repr(key) for key in unknown)} " + f"{'match' if plural else 'matches'} no {what}. " + f"Known {what} names: {known}. Note that names are hyphenated by the " "convention, so a config must be keyed the way the command line is typed." ) diff --git a/cw/testing.py b/cw/testing.py index 65a8ffc..634eff7 100644 --- a/cw/testing.py +++ b/cw/testing.py @@ -136,9 +136,7 @@ def normalise_text(text: str) -> str: """Newline-normalise ``text`` so a Mac recording asserts on a Windows runner. - This is the whole of the Windows decision (option A) as it applies to tier 1. It is - deliberately the *only* normalisation applied to recorded output -- anything else would - be forgiving a real difference. + This is the whole of the Windows decision (option A) as it applies to tier 1. >>> normalise_text('one\\r\\ntwo\\rthree\\n') 'one\\ntwo\\nthree\\n' @@ -150,6 +148,43 @@ def normalise_text(text: str) -> str: return text.replace("\r\n", "\n").replace("\r", "\n") +#: ``(choose from 'a', 'b')`` -- argparse quoted the items until 3.11 and stopped in 3.12. +_CHOOSE_FROM = re.compile(r"\(choose from [^)]*\)") + + +def canonical_argparse_text(text: str) -> str: + """Neutralise the *interpreter's* argparse rendering, so a golden replays anywhere. + + A golden records what **argh** printed on the machine that recorded it; the gate + replays it against what **cw** prints here. Those are only the same claim when both + sides are read through the same argparse. Two renderings are not — they are CPython's, + identical for argh and cw on any one interpreter, and different between interpreters: + + - the ``invalid choice`` message quoted its choices up to 3.11 and stopped in 3.12; + - the ``usage:`` block's line wrapping changed in 3.13 (a trailing ``...`` that used + to wrap now does not). + + Comparing those would assert the recording machine's Python version, not cw's grammar, + and cw's CI matrix spans 3.10 and 3.12. So both are canonicalised on *both* sides. + Nothing else is forgiven: this is the only normalisation beyond newlines, and each of + its two rules names the CPython change it answers. + + >>> canonical_argparse_text("x: error: invalid choice: 'q' (choose from 'a', 'b')") + "x: error: invalid choice: 'q' (choose from a, b)" + >>> canonical_argparse_text('usage: p [-h]\\n {a,b}\\n ...\\n\\nnext') + 'usage: p [-h] {a,b} ...\\n\\nnext' + + Text with neither is returned unchanged: + + >>> canonical_argparse_text('hello\\n') + 'hello\\n' + """ + if text is None: + return None + text = _CHOOSE_FROM.sub(lambda m: m.group(0).replace("'", ""), text) + return _USAGE.sub(lambda m: " ".join(m.group(0).split()), text) + + def normalise_usage(text: str) -> str: """The ``usage:`` block of ``text``, whitespace-collapsed and width-independent. @@ -598,6 +633,16 @@ def compare_case(recorded: dict, fresh: dict) -> str: stdout: - hi + ho + + A golden recorded under one CPython replays under another: only argparse's own + version-dependent rendering is forgiven, and it is forgiven on both sides (see + :func:`canonical_argparse_text`). + + >>> quoted = "err: invalid choice: 'q' (choose from 'a', 'b')" + >>> bare = "err: invalid choice: 'q' (choose from a, b)" + >>> compare_case({'tier': 3, 'returncode': 2, 'usage': quoted}, + ... {'tier': 3, 'returncode': 2, 'usage': bare}) + '' """ fields = TIER1_FIELDS if recorded.get("tier", 1) == 1 else TIER3_FIELDS chunks = [] @@ -607,7 +652,8 @@ def compare_case(recorded: dict, fresh: dict) -> str: if want != got: chunks.append(f"returncode:\n - {want!r}\n + {got!r}") continue - want, got = normalise_text(want), normalise_text(got) + want = canonical_argparse_text(normalise_text(want)) + got = canonical_argparse_text(normalise_text(got)) if want != got: chunks.append(f"{field}:\n" + _text_diff(want, got)) return "\n".join(chunks) diff --git a/docs/adr/0001-the-v1-seam-table.md b/docs/adr/0001-the-v1-seam-table.md new file mode 100644 index 0000000..1081bb5 --- /dev/null +++ b/docs/adr/0001-the-v1-seam-table.md @@ -0,0 +1,164 @@ +# ADR-0001: The v1 seam table + +- **Status:** accepted +- **Date:** 2026-08-30 +- **Deciders:** Thor Whalen +- **Issue:** [#8](https://github.com/i2mint/cw/issues/8) + +## Context + +cw replaces `argh` (LGPL-3.0-or-later) across a fleet in which ~34 repos will end up +declaring it and 47 console scripts already sit on the thing being replaced. The +`architecture-first` discipline asks for the seam table **before** the first commit, +because the boundaries decided in v1 are the ones every later iteration has to add at. + +The forcing constraint is decision **D2**: cw reproduces argh's grammar bit-for-bit by +default, and every improvement ships as a named value that defaults *off*. D2 buys the +migration — a repo swaps its dispatcher and its `--help` does not move — and it is also +what pushes this table past the shape `architecture-first` prefers. That tension is the +substance of this ADR, not a footnote to it. + +## Decision + +**Three seams. Each is exactly one keyword argument on `cw.dispatch` / `cw.mk_parser`. +Each default is a real, complete implementation — never a stub. Each has a replacement +that exists on disk today.** + +| # | Seam (one kwarg) | v1 default — real, not a stub | Replacement you can point at | +|---|---|---|---| +| 1 | **`decode=`** — how a parameter's type is inferred.
`(Parameter, hint) -> add_argument kwargs \| callable \| None` | `cw.argh_decode` — argh 0.31.3's two inference paths (the annotation guesser `TypingHintArgSpecGuesser`, `assembling.py:739-793`, and the default-value guesser `guess_extra_parser_add_argument_spec_kwargs`, `:310-364`) unified into one function with one precedence order. | **`cw.modern_decode`, shipping in v1** — `Optional[X]` unwrap, `Enum` by name-then-value, `pathlib.PurePath`. D2 mandates that it ship and that it default off. The *symptom* that motivates the seam is `lacing/cli.py:249-253`, which hand-writes `int()` in a function body under the comment *"argh delivers option values as strings; coerce here."* | +| 2 | **`egress=`** — result to lines to exit code.
`(result, *, out, err) -> int` | `cw.argh_egress` — argh's `isinstance(result, (GeneratorType, list, tuple))` whitelist (`dispatching.py:398`): a `dict` prints on one line, `None` prints nothing, `0`/`False`/`''` do print, generators stream lazily and flush per line. | **`cw.iterable_egress` and `cw.json_egress`, shipping in v1.** `t/xa/xa/cli.py:777` already hand-rolls `print(json.dumps(out, indent=2, default=str))` *inside a command body* because argh has no egress hook at all; `i/qh/qh/base.py:37 mk_json_egress` is the house's other egress, already written. | +| 3 | **`convention=`** — a re-binding of what the defaults ARE | `cw.ARGH` — nine fields, every one at argh's real value, every one asserted by the parity corpus. | **`cw.MODERN`, shipping in v1**, which D2 mandates. Precedent: `i/streamlitfront/streamlitfront/base.py:339` is `mk_app(objs, config=None, convention=None)` — the same two words with the same meanings, already shipping in the fleet. | + +``` +Surface for v1: CLI only. MCP / HTTP / frontend / shipped skills: asked, not built. + Would any of them need the core to change? NO -- py2mcp.mk_mcp_from_refs + (py2mcp/main.py:91) and qh.mk_fastapi_app (qh/base.py:103) consume the same + plain functions by string ref today. cw IS the CLI row of that table, not a + layer under it. Nothing in cw is shared with them, so nothing in cw has to + move when one of them changes. + +NOT seams: argparse itself -- it IS the product. argcomplete.autocomplete(argument_parser: + argparse.ArgumentParser, ...) is argparse-typed at the signature, so 10 fleet + repos and 10 `# PYTHON_ARGCOMPLETE_OK` markers survive an argh->cw migration + untouched, which the t/an typer migration could not do (an/__main__.py:21-25 + records that argcomplete had to be dropped). No `parser_backend=`. + . prog / description / epilog / formatter_class / allow_abbrev / conflict_handler + -- passed VERBATIM as **parser_kwargs to argparse.ArgumentParser. cw invents no + vocabulary for anything argparse already names. + . The config -> convention precedence ladder. Four fixed tiers, one function. + No pluggable resolver, no ChainMap subclass. (ADR-0003.) + . Short-flag inference. argh's first-character collision rule with -h suppression, + written directly as data. `Convention.short_flags: bool` turns it off; there is + no `short_flag_strategy=` callable. + . The flag append-merge rule. Load-bearing for byte-identical --help, written + directly. (ADR-0003.) + . The obj -> {name: func} discrimination rule. A callable value is a command, + a Mapping/Iterable value is a group. Structural, written directly. + . Completion. `try: import argcomplete` inside `enable_completion`, fired at + dispatch time, written fresh (argh's completion.py is LGPL). shtab is MPL-2.0 + and unadjudicated, so there is nothing to point at -- no `completion_backend=`. + . The exception -> exit-code policy. CommandError -> one line to err, exit(code); + everything else keeps its traceback; a SystemExit keeps its exit code. Written + directly: 4 fleet occurrences in 2 repos, and argh's wrap_errors / raw_output / + always_flush / skip_unknown_args / EntryPoint / ArghNamespace / + parse_and_resolve / run_endpoint_function have ZERO fleet uses across all 109 + argh-touching files. + . The output stream. `out=`/`err=` are plain parameters resolved at CALL time, + not seams. (This one line is what makes cw CLIs testable with capsys, which + argh CLIs are not -- argh binds `output_file: IO = sys.stdout` in a signature + default at dispatching.py:77.) + . The two-level nesting limit. argh's limit; xa and priv are the only group users. + . `cw.compat`'s eleven names. Fixed surface, matched to measured fleet usage. + . A Codec registry / a `Param` class / `cw.bind` / `set_dispatch_defaults`. + functools.partial(cw.dispatch, convention=..., prog=...) is the house dispatch, + written directly. Nothing to build. + . Async. Zero fleet occurrences. Not abstracted -- see ADR-0006. + . Colour / TTY detection. Absent. argh has none, the fleet uses none. +``` + +**The argcomplete census, because two documents got it wrong.** The canonical spec says +"7 fleet repos", an earlier draft of this ADR said eight. Both are wrong. Counted directly +over `$PP` (`grep -rl --include='*.py' PYTHON_ARGCOMPLETE_OK`, discarding cw's own two +source files, which only mention the marker in prose): **10 marker files across 10 repos** — +`t/article`, `t/coact`, `t/ek`, `t/ke`, `t/ocracy`, `t/openloops`, `t/paces`, `t/scribed`, +`t/skill`, `tt/reelee` — and every one of the ten is an argh consumer. That is the number +this row is about, and it is larger than either document claimed. + +### Three things this table must say honestly, because the review caught them + +**1. Seam 1's headline pointer in the canonical spec was wrong.** +`cw/resolution.py:318 resolve_to_function` is `(str) -> callable` — a *value converter*. +`Decode` is a *grammar inferencer*: it answers "what `add_argument` kwargs does this +parameter get". The spec itself proves at length that `resolve_to_function` must **not** +sit at argparse's `type=` site. The replacement to point at is `cw.modern_decode`; +`lacing/cli.py:249-253` is the symptom, not the replacement. + +**2. The honest surface count is nine, not three.** +`convention=` is one keyword argument carrying a frozen dataclass of **nine** fields — +`naming`, `short_flags`, `hyphenate_commands`, `hyphenate_groups`, `default_in_help`, +`hints_when_declared`, `resolve_hints`, plus `decode` and `egress`, which *are* seams 1 +and 2 given a per-context home. Counted as switches a caller can flip, that is seven +grammar switches plus two seams: **nine**, past `architecture-first`'s stop-and-ask +ceiling of seven. + +We stopped, asked, and shipped it anyway. **D2 forced it.** "Every improvement ships as a +named convention value that defaults off" is not satisfiable with fewer switches, because +each switch is a place where argh's behaviour and the better behaviour genuinely differ +and both must be reachable. The alternative — one boolean `modern=True` — was rejected: it +makes every future improvement a breaking change to the meaning of a flag, which is the +opposite of what D2 buys. + +(Issue #8 estimated twelve. Nine is the shipped number; `formatter_class` was cut by +ADR-0006 as duplicated by `**parser_kwargs`, and the draft's remaining count was a +double-count of `decode`/`egress` as both fields and seams.) + +**3. `architecture-first` test 4 — "v1 ships exactly one implementation per seam" — is +deliberately spent.** cw ships two decoders, three egresses and two conventions: + +| seam | implementations shipped in v1 | +|---|---| +| `decode=` | `argh_decode` (default), `modern_decode` | +| `egress=` | `argh_egress` (default), `iterable_egress`, `json_egress` | +| `convention=` | `ARGH` (default), `MODERN` | + +This is priced, not overlooked. Test 4 exists to stop "and here's the S3 one too, for +later" — a second implementation with no caller. Here the second implementation *is* the +product: D2's promise is precisely "the improvement exists and is one keyword away", and a +`MODERN` that does not exist makes the promise unverifiable. `json_egress` is the weakest +of the three (ADR-0006 examines it and keeps it, at 12 statements). + +Tests 1–3 hold unspent: every seam names a replacement that exists on disk, every seam is +one keyword argument with no base class or registry behind it, and the count stopped at +three. + +## Consequences + +- **Adding a seam later requires amending this ADR.** The `NOT seams:` list above is + binding; a fourth keyword argument that switches behaviour is a change to this table, + written as a new ADR that supersedes it. +- Every default named here is a working implementation, asserted by + `python -m cw.testing parity` (8 shapes / 133 cases against goldens recorded from live + argh 0.31.3) and by `tests/argh_parity/` (a live differential when `cw[dev]` is + installed). A seam whose default became a stub would fail the gate, not merely read + badly. +- `convention=` being one argument that carries nine fields means "flip cw to modern + behaviour" stays one act at one call site — which is what makes the nine survivable. +- The `Surface for v1: CLI only` line is a commitment that the *core* owes nothing to a + future MCP/HTTP surface. If one arrives and needs the core to change, that is evidence + this line was wrong, and it gets its own ADR. + +## Alternatives considered + +- **Fold `decode` and `egress` into `convention` only** (two seams, not three). Rejected: + the one-off case — this call, this function, this egress — is the common one, and + forcing a caller to construct a `Convention` to change one thing fails progressive + disclosure. +- **A `parser_backend=` seam** so cw could sit over `click` or `typer` later. Rejected by + test 1 and by the whole point of the package: argparse *is* the product, because + `argcomplete.autocomplete` is argparse-typed at its signature and ten fleet + `# PYTHON_ARGCOMPLETE_OK` markers depend on that. +- **A `name_of=` command-naming hook.** Built and tested in a candidate, then cut: the + `{name: func}` mapping form covers its only fleet case (`t/xa`), and xa needs that form + anyway for `gen-secret` and the `archive` group. Two ways to do one thing is not a seam. +- **`out=` as a fourth seam.** It is a parameter *of* the egress seam, not a peer of it. diff --git a/docs/adr/0002-the-ingress-stash.md b/docs/adr/0002-the-ingress-stash.md new file mode 100644 index 0000000..ab9da5e --- /dev/null +++ b/docs/adr/0002-the-ingress-stash.md @@ -0,0 +1,208 @@ +# ADR-0002: How a plain `ArgumentParser` carries convention, config and ingress into `run` + +- **Status:** accepted +- **Date:** 2026-08-30 +- **Deciders:** Thor Whalen +- **Issue:** [#9](https://github.com/i2mint/cw/issues/9) + +## Context + +cw's most load-bearing constraint is that `mk_parser` returns a **plain** +`argparse.ArgumentParser`, never a subclass: + +```python +def mk_parser(obj, /, *, config=None, convention=ARGH, decode=None, **parser_kwargs) -> argparse.ArgumentParser +def run(parser, argv=None, *, ...) -> int +``` + +That is what keeps `argcomplete` alive across ten fleet repos: `argcomplete.autocomplete` +is argparse-typed at its signature, and ten `# PYTHON_ARGCOMPLETE_OK` markers survive the +migration untouched only because cw hands over the real thing. `argh` gets to keep state on +`ArghParser` (a subclass). cw cannot. + +But `run` needs state that only `mk_parser` knew: **which function** this subcommand calls, +**how** to turn the parsed `Namespace` into that function's `(args, kwargs)`, and **under +which convention** the whole thing was built. A build-and-run pass on the prototype found +three defects that made `run` unimplementable as specified: + +1. **`run` had no `convention=`.** The spec says `egress=None` means "take the + convention's" — and `run` had no way to reach one. A `MODERN` parser run through the + published `run` silently got `ARGH`'s egress, which also falsifies the spec's own + argument that `convention=cw.MODERN` is "one act". +2. **`run(parser)` had no channel for `config`**, so per-parameter codecs could not reach + the ingress. The prototype invented `parser.set_defaults(_cw_config=...)` — an unnamed + private key that would collide with a parameter of that name. +3. **`run`'s stated reason for existing did not work.** The published API had no + `set_default_command`; the "hand-build a parser and still use cw's ingress/egress" + justification only worked through a private function. + +This is the trickiest mechanism in cw, and it is non-obvious to a reader who *expects* a +parser subclass. Hence this ADR. + +## Decision + +### 1. One reserved `set_defaults` key: `'_cw'` + +`cw.cli.RESERVED_DEST = '_cw'`. Every parser and subparser cw builds carries exactly one +extra namespace entry under that key, holding a frozen `_Stash`: + +```python +@dataclass(frozen=True) +class _Stash: + convention: Convention + func: Optional[Callable] = None # None on a parser that only holds subcommands + ingress: Optional[Callable] = None + config: Optional[Mapping] = None +``` + +This is the same mechanism argh uses (`parser.set_defaults(function=...)` + +`DEST_FUNCTION`, `argh/constants.py:54`), with cw's name reserved and its collision made +loud. `run` reads it back with `parser.get_default(RESERVED_DEST)` before parsing and +`getattr(namespace, RESERVED_DEST)` after. + +**A parameter named `_cw` is a startup error, never silent corruption:** + +``` +>>> import cw +>>> def f(_cw=1): ... +>>> cw.mk_parser(f) +Traceback (most recent call last): + ... +cw.grammar.GrammarError: f: the parameter '_cw' collides with the namespace key cw +reserves for its own use. Rename the parameter, or hide it with cw.HIDE. +``` + +(Without the check, argh's own grammar would have inferred the option strings +`['--', '---cw']` from `_cw` and argparse would have rejected them with a message +mentioning neither cw nor the parameter. `cw.HIDE` is the documented workaround.) + +`_cw` is stripped from the values before the ingress runs, so a command never sees it. + +### 2. The stash carries convention and config; `run`'s keywords override + +`run` gains **both** `convention=` and `config=`, and both default to `None` meaning "use +the stash's". Resolution order, in `run`: + +```python +stash = parser.get_default(RESERVED_DEST) # the top parser's +stash = getattr(namespace, RESERVED_DEST, None) or stash # the CHOSEN subcommand's wins +convention = convention or stash.convention # run's keyword wins over both +egress = egress if egress is not None else convention.egress +``` + +**The chosen subcommand's stash beats the top parser's**, because it is the one that knows +which function was selected and under which convention it was built. A group added with +`add_commands(..., convention=MODERN)` inside an `ARGH` parser is rare, and getting it +wrong would be silent. + +So `convention=cw.MODERN` stays *one* act even when build and run are two calls: + +``` +>>> import cw, io +>>> def counted(): +... return map(str, range(2)) +>>> parser = cw.mk_parser(counted, convention=cw.MODERN) +>>> out = io.StringIO() +>>> cw.run(parser, [], out=out) +0 +>>> out.getvalue() +'0\n1\n' +``` + +(`map` is not in argh's `(GeneratorType, list, tuple)` whitelist, so under `ARGH` this +prints ``. The two lines are `MODERN`'s `iterable_egress` arriving +through the stash.) + +A `config` codec reaches the ingress either way — baked in at build time, or passed to +`run`: + +``` +>>> def load(spec='builtins.len'): +... return spec.__name__ if callable(spec) else f'not resolved: {spec!r}' +>>> codec_config = {'spec': {'codec': cw.resolve_to_function}} +>>> out = io.StringIO() +>>> cw.run(cw.mk_parser(load, config=codec_config), ['--spec', 'builtins.sum'], out=out) +0 +>>> out.getvalue() +'sum\n' +>>> out = io.StringIO() +>>> cw.run(cw.mk_parser(load), ['--spec', 'builtins.sum'], out=out, config=codec_config) +0 +>>> out.getvalue() +'sum\n' +``` + +**Caveat, and it is a real one:** `run(config=...)` re-inspects the function to rebuild the +ingress, so only the `codec=` half of a leaf can still take effect that late. The rest of a +leaf is token grammar and is already frozen into the parser — passing +`config={'x': {'help': 'h'}}` to `run` does nothing to `--help`. Pass `config` to +`mk_parser`/`dispatch` unless you specifically want a late codec. + +### 3. `set_default_command` is exported + +`cw.set_default_command(parser, func, *, config=None, convention=ARGH)` binds a function +onto a parser you built yourself and stashes its ingress, so `run`'s "a repo can hand-build +a parser and still use cw's ingress and egress" is a true claim rather than an aspirational +one. (`cw.add_commands` is its multi-command sibling; see ADR-0006 on why it survived the +cut list.) Binding onto a parser that already declared a colliding flag raises +`argparse.ArgumentError` — argparse's own error, at the point of the collision. + +### 4. `argv` is POSITIONAL_OR_KEYWORD on both `run` and `dispatch` + +Not positional-only. Four fleet call sites spell it as a keyword (`lacing/cli.py:290`, +`illustration/__main__.py:27`, `ov/__main__.py:190`, `hearing/cli.py:297`), and with a +positional-only `argv` the `**parser_kwargs` catch-all swallows the keyword and produces +`TypeError: ArgumentParser.__init__() got an unexpected keyword argument 'argv'` — an error +that names neither cw nor `argv`'s real position. Relatedly, an unknown `**parser_kwargs` +key now raises a `TypeError` that names cw and lists what `argparse.ArgumentParser` +actually accepts. + +### 5. `out=`/`err=` capture argparse's output, but never a command body's `print` + +`--help`, `usage:` and `error:` come from argparse, not from egress, so `out=`/`err=` can +only capture them by redirecting. cw redirects **around `parser.parse_args` only** — never +around the command body: + +```python +with _redirect(out if out is not sys.stdout else None, ...): + namespace = parser.parse_args(argv) +``` + +So `out=io.StringIO()` captures `--help`, the usage line and argparse's `error:`, while a +command's own `print()` goes exactly where the process's `print` goes — as it does under +argh. The redirect is a process-global mutation, and confining it to the parse means it is +held for microseconds and cannot swallow anything a command chose to write itself. A +command that wants its output captured should **return** it; that is what the egress seam +is for. + +## Consequences + +- cw can never keep per-subcommand state as a parser *attribute*, and should not start: + `_cw` is the one channel, and adding a second would reintroduce exactly the ambiguity + this ADR closes. +- `_cw` is now a name cw has taken. It is not a plausible parameter name, the collision is + a startup error naming the fix, and `cw.HIDE` is the escape hatch. +- `run` is genuinely usable on a hand-built parser, which is what makes `t/coact`'s test + suite monkeypatch-free. +- A `Namespace` produced by cw carries one entry a caller did not ask for. Anything reading + the raw namespace (nothing in the fleet does) must skip `_cw`. +- The redirect decision means `capsys` tests of a cw CLI see command output on the real + stdout and argparse's output in `out=`. That is a divergence from the spec's marketing + ("what makes cw CLIs testable with capsys") and is documented rather than papered over. + +## Alternatives considered + +- **Subclass `ArgumentParser`** and keep the state as attributes. Rejected outright: it is + the one thing that would break `argcomplete` across ten repos, and it is the reason cw + is argparse-based at all. +- **A module-level `WeakKeyDictionary[parser -> stash]`.** Works, and keeps the namespace + clean, but it is invisible global state that does not survive pickling, does not survive + a parser crossing a process boundary, and gives a debugging reader nothing to `print`. + The `set_defaults` key shows up in `parser.get_default` and in `vars(namespace)`, which + is how argh does it and how it should be found. +- **Require `run` to be given everything explicitly** (no stash at all). Rejected: it makes + `dispatch` the only ergonomic entry point and turns "build here, run there" — the shape + 19 fleet call sites already use — into a chore. +- **Redirect around the whole call** so `out=` captures command `print`s too. Rejected: it + diverges from argh (a D2 break), holds a process-global mutation for the entire command + body, and would silently capture output a command deliberately sent elsewhere. diff --git a/docs/adr/0003-the-merge-ladder.md b/docs/adr/0003-the-merge-ladder.md new file mode 100644 index 0000000..85b0a42 --- /dev/null +++ b/docs/adr/0003-the-merge-ladder.md @@ -0,0 +1,189 @@ +# ADR-0003: The four-tier merge ladder is argh's field-specific merge, not `dict.update` + +- **Status:** accepted +- **Date:** 2026-08-30 +- **Deciders:** Thor Whalen +- **Issue:** [#10](https://github.com/i2mint/cw/issues/10) + +## Context + +cw's whole `config` story rests on one sentence: *"leaf values **are** `add_argument` +kwargs, and `config` is merged **over** the inferred spec, later wins."* Read as a flat +`dict.update`, that sentence is wrong twice, and under D2 (bit-for-bit argh) it is an +unclosed hole in the thing everything else sits on. + +**argh's real merge**, read at source (`argh/dto.py:33-50`): + +```python +def update(self, other): + for name in other.cli_arg_names: + if name not in self.cli_arg_names: + self.cli_arg_names.append(name) # APPEND if absent + if other.is_required != NotDefined: + self.is_required = other.is_required # only if defined + if other.default_value != NotDefined: + self.default_value = other.default_value # only if defined + if other.nargs: + self.nargs = other.nargs # only if TRUTHY + if other.completer: + self.completer = other.completer + self.other_add_parser_kwargs.update(other.other_add_parser_kwargs) +``` + +and `make_from_kwargs` (`dto.py:66-88`) **pops** `required` / `nargs` / `default` out of the +kwargs dict before any of that happens. A flat `dict.update` diverges on a falsy `nargs`, on +`required=False`, and on any override that wants to unset. + +**The second, worse hole: which tier suppresses type-hint inference.** argh's +`assembling.py:419` is `can_use_hints = not declared_args`, keyed off `@arg` — tier 3. The +spec said tier 2 is skipped when *tier 3* is non-empty and said nothing at all about tier 4 +(`config`). But **every worked migration in the spec moves declarations from tier 3 into +tier 4** (`theremin`'s `CLI_CONFIG`, `coact`'s `CONFIG`), so those migrations would silently +re-enable hint inference that argh had switched off. theremin and coact happen to survive +because `str` is idempotent through `type=`. That is luck, not a rule — ~32 fleet files +carry annotations argh currently ignores. + +## Decision + +### 1. The merge is field-specific, reimplemented per `dto.py:33-50` + +`cw.grammar.ArgSpec.update` is four rules, none of which is `dict.update`: + +```python +for flag in other.flags: # APPEND, never replace + if flag not in self.flags: + self.flags.append(flag) +if other.required is not MISSING: # only when the override HAS one + self.required = other.required +if other.default is not MISSING: + self.default = other.default +if other.nargs: # only when TRUTHY + self.nargs = other.nargs +if other.codec is not None: + self.codec = other.codec +self.extra.update(other.extra) # everything else +``` + +`cw.MISSING` plays argh's `NotDefined` role, which is why `required=False` and +`default=None` are distinguishable from "not mentioned" — `None` cannot carry that +distinction because `None` is an ordinary default. + +The **flag append** rule is the one with visible consequences. `t/theremin`'s three +parameters `synth`, `scale` and `seconds` all begin with `s`, so argh's collision rule +gives none of them a short flag; `@arg('--synth', '-s')` then *appends* after the inferred +`--synth`, and `--help` reads `--synth [SYNTH], -s [SYNTH]` — long first. cw reproduces +that with **no special case anywhere**; it falls out of append-not-replace. + +### 2. Tier 4 (`config`) suppresses hints exactly as tier 3 (`@arg`) does — under `ARGH` + +`convention.hints_when_declared` reads "declared" as tier 3 **or** tier 4: + +```python +use_hints = convention.hints_when_declared or not (declared or config) +``` + +`ARGH.hints_when_declared` is `False`; `MODERN.hints_when_declared` is `True`. + +This is what makes moving an `@argh.arg` into a `config` entry **behaviour-preserving**, +which is the entire purpose of D2. Verified: + +``` +>>> import cw +>>> from cw import compat +>>> def h(*, n: int = None): +... return repr(n) +>>> cw.dispatch(h, ['-n', '5']) # hints ON -> int +5 +0 +>>> cw.dispatch(h, ['-n', '5'], config={'n': {'help': 'count'}}) # hints OFF -> str +'5' +0 +>>> @compat.arg('-n', '--n', help='count') +... def h2(*, n: int = None): +... return repr(n) +>>> cw.dispatch(h2, ['-n', '5']) # hints OFF -> str +'5' +0 +>>> cw.dispatch(h, ['-n', '5'], config={'n': {'help': 'count'}}, +... convention=cw.MODERN) # MODERN keeps hints +5 +0 +``` + +(The trailing `0` on each is `dispatch`'s exit code; the line above it is what the command +printed.) + +`config` and `@arg` now give identical behaviour under `ARGH`, which the spec claimed and +a `dict.update` reading would have made false. + +**This is a footgun and we are keeping it**, because it is argh's: under `ARGH`, adding a +`help` string to *one* parameter changes the *type coercion* of *every other parameter* of +that function. It is documented in `specs_for_function`'s docstring and in the README. +`convention=cw.MODERN` is the way out. + +### 3. There is no spelling that unsets a value, and that is recorded rather than invented + +argh has none. cw reproduces `if other.nargs:` exactly, so a falsy `nargs` in an override is +ignored: + +``` +>>> from cw.grammar import specs_for_function +>>> def g(*agents): ... +>>> [(s.flags, s.nargs) for s in specs_for_function(g)] +[(['agents'], '*')] +>>> [(s.flags, s.nargs) for s in specs_for_function(g, config={'agents': {'nargs': None}})] +[(['agents'], '*')] +``` + +We considered making `cw.MISSING` mean "unset" as an override value and **did not**: no +fleet call site wants it, argh has no equivalent, and inventing a spelling under D2 means +`ARGH` would no longer mean "argh's grammar". If a repo ever needs it, `MISSING`-as-unset is +the spelling to add, and it is an addition to `ArgSpec.update` — a boundary that already +exists. + +### 4. The ladder is one function, and it lives in `cw.grammar` + +`cw.grammar.specs_for_function` is the only implementation of the four tiers: + +``` +1. signature inference kind, default, name, flag spellings +2. hint inference decode(param, hint) <- skipped per rule 2 +3. function attribute func._cw['params'][param] (cw.compat.arg writes it) +4. config config[param] (this call's particulars) +``` + +Issue #10's acceptance criterion asked for it in `cw/convention.py`. It is in +`cw/grammar.py` instead, because `grammar` is the only module that knows what an `ArgSpec` +is: putting the ladder in `convention` would have made `convention` import `grammar`'s +internals and `grammar` import `convention`'s, a cycle for no gain. `cw/convention.py` +*documents* the ladder and this ADR is cited in both docstrings. + +## Consequences + +- Nine parity-corpus cases exist for the merge specifically: falsy `nargs` + (`declared_falsy_nargs`), `required=False`, `@arg`-vs-`config` equivalence, + `def h(*, n: int = None)`, and the theremin flag-append shape. A mutation that replaces + the field-specific merge with `dict.update` turns **15** parity cases red; one that makes + falsy `nargs` merge unconditionally turns **2** red. Both were run. +- Under `ARGH`, `config` is not a "local" override: touching one parameter changes the whole + function's type inference. Anyone surprised by a `str` where they expected an `int` should + look for a `config` entry or an `@arg` elsewhere in the same function first. +- There is a documented hole — no unset — and it is a hole argh has too. It is not a defect + to be fixed quietly in a patch release; see ADR-0005 on why `ARGH` is frozen. +- One argh defect this ladder faithfully reproduces, found while building it and in no spec + section: argh's **default-value guesser runs after the merge and overwrites a declared + `nargs`**. `@arg('--xs', nargs='+')` on `def f(*, xs=[])` yields `nargs='*'`, not `'+'`, + because `get_all_kwargs` returns `dict(field_kwargs, **other_add_parser_kwargs)`. Pinned + by `declared_nargs_loses_to_default`. If a migrated CLI looks wrong, check this first. + +## Alternatives considered + +- **Flat `dict.update`**, as the spec's prose implied. Rejected: it diverges observably on + falsy `nargs` and on `required=False`, and it *replaces* flags rather than appending them, + which changes theremin's `--help` line. Under D2 that is a bug, not a simplification. +- **Tier 4 does not suppress hints** (the literal reading of `can_use_hints = + not declared_args`). Rejected: it makes every "move your `@arg`s into `config`" migration + a silent behaviour change, and the spec instructs exactly that migration seven times. +- **Nothing suppresses hints** (always resolve annotations). That is `MODERN`, and it ships + — as an opt-in, per D2. +- **Invent `cw.MISSING` as an unset spelling.** Deferred, not rejected: no consumer. diff --git a/docs/adr/0004-grammar-errata.md b/docs/adr/0004-grammar-errata.md new file mode 100644 index 0000000..3e4136f --- /dev/null +++ b/docs/adr/0004-grammar-errata.md @@ -0,0 +1,175 @@ +# ADR-0004: Grammar errata — `group_kwargs`, Mapping-key naming, and MODERN's help column + +- **Status:** accepted +- **Date:** 2026-08-30 +- **Deciders:** Thor Whalen +- **Issue:** [#11](https://github.com/i2mint/cw/issues/11) + +## Context + +Four corrections that verification passes found in the canonical spec. Each of them, written +as specified, would have shipped a wrong CLI — and three would have done it *silently*. +They are grouped in one ADR because they share a cause: names and display strings crossing +the boundary between cw's vocabulary and argparse's, where "obviously equivalent" is not. + +## Decision + +### 1. `group_kwargs['help']` is not invisible. Pass `group_kwargs` whole; drop nothing + +argh (`assembling.py:669-672`): + +```python +subsubparser = subparsers_action.add_parser( + group_name, help=group_kwargs.get("title") +) +subparsers_action = subsubparser.add_subparsers(**group_kwargs) +``` + +The spec's finding was that `title` — not `help` — drives the **parent listing row**. That +part is right, and it means `t/xa`'s `group_kwargs={"help": "Postmortem archive (list, log, +forensics)."}` displays nothing in `xa --help` today. The spec's *instruction* — "DROPPED, +not translated" — is wrong: the whole `group_kwargs` also goes to `add_subparsers`, `help` +included, so it renders **inside** `xa arch --help`. + +**Rule: pass `group_kwargs` whole to `add_subparsers`, and `help=group_kwargs.get('title')` +to `add_parser`. Drop nothing.** Verified in cw: + +``` +$ xa --help $ xa arch --help +positional arguments: usage: xa arch [-h] {g-one,g-two} ... + {arch} + arch ARCH TITLE ARCH TITLE: + {g-one,g-two} Postmortem archive. +``` + +**Corollary that must survive refactoring:** `help=` is passed to `add_parser` **even when +it is `None`**. Without it argparse never creates the `_ChoicesPseudoAction`, and the group +row **vanishes** from the parent `--help` entirely — a silent regression in the fleet's only +two group users (`t/xa`, `t/priv`). Verified: with no `group_kwargs` at all, the `arch` row +is still present and simply carries no text. + +A mutation that reads `help` instead of `title` turns 5 tests red; one that drops +`group_kwargs` from `add_subparsers` turns 4 red; one that omits `help=` from `add_parser` +turns 8 red. All three were run. + +### 2. One naming function, applied to derived names **and** to Mapping keys **and** to config keys + +The spec contradicted itself in a single sentence: *"a key wins **verbatim**, so +`'gen-secret'` → `gen-secret`, `'list'` → `list`, priv's `'parse_pth_paths'` → +`parse-pth-paths`"*. Verbatim and `parse_pth_paths → parse-pth-paths` cannot both hold. + +**Rule: a key or `__all__` entry beats `__name__`, and is then hyphenated by the +convention.** It is the only reading that satisfies all three examples at once: + +``` +>>> import cw +>>> def f(): ... +>>> def g(): ... +>>> def h(): ... +>>> sorted(cw.commands_from({'gen-secret': f, 'list': g, 'parse_pth_paths': h})) +['gen-secret', 'list', 'parse-pth-paths'] +``` + +`cw.grammar.cli_name` is that one function, and it is applied to command names, group names, +flag spellings **and** config keys. A mutation that lets a key win verbatim turns 17 tests +red. + +**`config` is therefore keyed the way you type it on the command line, not the way the +Python identifier is spelled.** This is the single most likely thing for a migration to get +wrong, because it makes two adjacent dicts in the same file use two spellings — `COMMANDS` +keyed `packages_from_all_setup_cfgs` (from `__all__`), `CONFIG` keyed +`packages-from-all-setup-cfgs`. + +### 3. A `config` key naming no command, group or parameter is a **hard error** + +Which is what makes rule 2 survivable, and what closes the `MODERN` trap: + +``` +>>> import cw +>>> def g_one(x=1): ... +>>> cw.mk_parser({'git_ops': {'g_one': g_one}}, +... config={'git_ops': {'g-one': {'x': {'help': 'H'}}}}, +... convention=cw.MODERN) +Traceback (most recent call last): + ... +cw.grammar.GrammarError: config key 'git_ops' matches no command or group. Known command +or group names: git-ops. Note that names are hyphenated by the convention, so a config +must be keyed the way the command line is typed. +``` + +Without the check, `convention=cw.MODERN` — which sets `hyphenate_groups=True` — would +rename the group, invalidate every config entry keyed by the old name, and produce a CLI +whose help text quietly lost a line. No error, no warning. With the check, the same call is +a startup failure that names the old key, the new name, and the reason. + +The same rule holds one level down: `cw.grammar.specs_for_function` raises when a `config` +key matches no parameter, listing the real parameter names. The one exception is a function +that takes `**kwargs`, where an unmatched key becomes an argument delivered there — argh's +rule. + +**A known gap, recorded rather than hidden:** there is no `dispatch(config=...)` spelling for +*group-level parser keywords*. `config[group]` is a mapping of commands, so a `'title'` key +inside it is (correctly) an error saying it matches no command. Group kwargs reach only +through `cw.add_commands(group_kwargs=...)`. The canonical spec §14.4 suggests +`config={'archive': {'title': ...}}` as xa's post-migration escape hatch; that spelling does +not exist. If a repo needs it, the addition is a per-group channel on `dispatch`, and it is +a new ADR. + +### 4. `MODERN` keeps `default_in_help=True` + +The spec gave `MODERN` `default_in_help=False`, which removes argh's `help='%(default)s'` +wart. But docstring-derived per-parameter help is **not in v1** (ADR-0006), so removing the +default leaves an undocumented flag with **no help column at all**: + +``` +ARGH MODERN as specified + -v, --verbose False -v, --verbose + -r RETRIES, --retries RETRIES 3 -r RETRIES, --retries RETRIES +``` + +`MODERN` is what the spec tells `t/priv` and `t/lacing` to flip to, so as specified it makes +their `--help` strictly worse than the argh they are leaving. + +**Rule: `MODERN.default_in_help` stays `True` until docstring-derived help ships.** Showing +the default is a poor substitute for a description, but it is more information than a blank +column, and D2's promise is that `MODERN` is an *improvement*. + +`Convention` also has **no `formatter_class` field** (ADR-0006 cut it as duplicated by +`**parser_kwargs`). A caller who wants argparse's own defaults renderer passes it where +argparse names it: + +```python +cw.dispatch(COMMANDS, convention=cw.MODERN, + formatter_class=argparse.ArgumentDefaultsHelpFormatter) +``` + +## Consequences + +- The parity corpus covers rule 1 with `group_kwargs` in three shapes — + `{'help', 'title'}`, `{'help'}` only, and absent — asserting the parent row *and* the + child `--help` in each. `t/xa`'s shape is the only one that reaches that code path, which + is why ADR-0006 keeps `cw.add_commands` in the core. +- Rule 2 means a migration checklist item: **key `config` the way the command line reads.** + It is in the README's migration section. +- Rule 3 converts a class of silent misconfiguration into startup failures. The cost is that + a `config` written for one convention will not load under another until its keys are + updated — which is the point. +- Rule 4 means `MODERN`'s help column still shows `repr(default)`. When docstring help + ships (as `cw[docs]`, lazily importing `i2.doc_mint`), flipping `default_in_help` to + `False` in `MODERN` becomes correct — and per ADR-0005 that is a change to `MODERN`, never + to `ARGH`. + +## Alternatives considered + +- **Translate `group_kwargs['help']` to `add_parser(help=...)`** so xa's string finally + displays in the parent row. Rejected: it is a visible D2 break, and the string is still + displayed — one level down, where argh puts it. A repo that wants it in the parent row + adds `'title'`. +- **Keys win verbatim** (the spec's first clause). Rejected: `parse_pth_paths` would become + a command you invoke as `priv parse_pth_paths` while its neighbours hyphenate — argh + hyphenates it today, so it is a D2 break and a fleet-visible one. +- **A `config` key that matches nothing is a warning, not an error.** Rejected: warnings on + a console script go to stderr in production and are read by nobody. The failure mode being + closed is silence, and a warning is a quieter silence. +- **Keep `MODERN.default_in_help=False` and ship docstring help in v1.** Rejected on scope: + it needs `i2.doc_mint`, which is the one place i2 earns its way back in, behind an extra. diff --git a/docs/adr/0005-release-and-rollback-policy.md b/docs/adr/0005-release-and-rollback-policy.md new file mode 100644 index 0000000..66f4256 --- /dev/null +++ b/docs/adr/0005-release-and-rollback-policy.md @@ -0,0 +1,239 @@ +# ADR-0005: cw's release, pinning and rollback policy for the fleet + +- **Status:** accepted +- **Date:** 2026-08-30 +- **Deciders:** Thor Whalen +- **Issue:** [#12](https://github.com/i2mint/cw/issues/12) + +## Context + +cw is not an ordinary package on this fleet. 62 repos carry `argh` lines, ~34 will end up +declaring `cw`, and 47 console scripts sit across the repos being migrated. And on this +fleet **a merge to the default branch is a PyPI upload the same day**: cw is wads-managed +(`i2mint/wads/.github/workflows/uv-ci.yml@master`, `[tool.wads.ci.publish] enabled = true`), +so merge → automatic version bump → upload, with no human step between. + +That combination means a grammar bug in cw 0.1.1 is a **fleet-wide incident** — 34 repos +whose CLIs change shape at their next `pip install` — and until this ADR there was no +stated containment for it. This is the one gap in the whole cw programme that costs a +fleet-day rather than a repo-hour. + +The special hazard is D2. cw's value proposition is "your `--help` does not move". A +package whose grammar can drift between patch releases has no value proposition at all, +because "the fleet's tests still pass" would stop meaning anything. + +## Decision + +### 1. The dependency spelling every repo uses + +```toml +dependencies = ["cw>=0.1,<0.2"] +``` + +Verbatim, in every migrating repo's `pyproject.toml`. It is the copy-paste line in the +README's migration section. + +Rationale: `>=0.1` because the migration needs the v1 API; `<0.2` because cw is a 0.x +package and, by the rule below, a `0.2` is where a grammar change would live. `cw~=0.1` is +the same constraint spelled less obviously; a bare `cw` is what makes a bad release a fleet +incident instead of a repo incident. + +Repos that install cw as an optional CLI extra (`t/ocracy`, `t/scribed`) use the same +specifier inside their `[cli]` extra. + +### 2. `cw.ARGH` is frozen once published + +**A change to the grammar under `cw.ARGH` is not a patch, not a minor version, and not +allowed. It is a new named `Convention` value.** + +This is the load-bearing rule of the whole policy. `ARGH` means "argh 0.31.3's grammar". If +`ARGH` can drift, D2 buys nothing and the committed goldens stop being a contract. So: + +- A *bug* in cw's reproduction of argh — where cw and argh 0.31.3 genuinely differ — is a + fix, ships as a patch, and shows up as a golden diff that has to be reviewed line by line. +- A *change* to what the grammar ought to be — better `Optional[X]` handling, hyphenated + groups, docstring-derived help — never touches `ARGH`. It goes into `MODERN`, or into a + new named value beside it. +- Consequently, **a non-empty `git diff cw/tests/goldens/` in a PR is a review gate.** Those + bytes are the contract. Re-recording an unchanged fixture is byte-identical, so any diff + at all is a real behaviour change. + +### 3. The grammar-freeze test — cw's CI is the release gate + +`python -m cw.testing parity` replays 8 shapes / 133 cases against goldens recorded from +live argh 0.31.3 and committed to the repo. It runs in cw's own CI on every push, on +3.10 and 3.12, on Linux, macOS and Windows. **An `ARGH` drift therefore fails cw's CI +before the publish step runs**, because wads' publish job is gated on the test job. + +It is falsifiable, not decorative: nine plausible reimplementation mistakes were each +introduced deliberately and each turned the gate red (36, 17, 15, 3, 2, 2, 2, 3 and 34 +differing cases respectively). A change that passes the whole suite *and* survives swapping +`store_false` for `store_true` means a corpus case is missing, not that the change is safe. +The battery was re-run on 3.10 and on 3.12 and produced the **same nine counts** on both, +which is the evidence for the paragraph below. + +**A golden is recorded on one interpreter and asserted on the whole matrix, so the gate +must not assert the recording machine's CPython version.** It did, and that made a correct +cw red on 3.10: `argparse` quoted the choices in its `invalid choice` message up to 3.11 +and stopped in 3.12, and it rewrapped the `usage:` block's trailing `...` in 3.13. Neither +is cw's output — argh and cw print the same bytes as each other on any one interpreter — +so `cw.testing.canonical_argparse_text` canonicalises exactly those two renderings, on +both sides of every comparison, and nothing else. Two rules, each naming the CPython change +it answers; adding a third is a change to the gate's meaning and needs the mutation battery +re-run to show it still bites. With it the gate is `identical` on 3.10, 3.11, 3.12 and 3.13. + +### 4. Yank policy + +A released version is yanked when — and only when — it would have failed the parity gate, +i.e. an `ARGH` grammar regression escaped. Thor executes it (`twine`/PyPI web UI); it is not +delegated, because a yank is a permanent, public statement about a version number. + +Two things a yank does **not** do, which is why it is the *second* action and never the +first: + +- It does not touch an environment where the bad version is already installed. Only the + pin-back does. +- It does not free the version number. A PyPI version is burned permanently once used; the + fix ships as the next number. + +### 5. The rollback drill — executed, not merely written + +**The drill.** A consumer pinned `cw>=0.1,<0.2` resolves to the newest matching release. cw +0.1.1 is published carrying a grammar regression (short-flag collision suppression dropped +— argh's rule that if two parameters share a first letter, *neither* gets a short flag). +Rolling back is one `pip install` with `==` and the last-good version. + +The transcript below is real. Two wheels were built from this tree — `cw 0.1.0` unmodified, +`cw 0.1.1` with `_flag_spellings`'s collision check removed — a throwaway consumer `demo` +declaring `cw>=0.1,<0.2` was installed into a fresh venv, and both halves were run. + +``` +$ pip install --no-index --find-links ./wheels demo # cw>=0.1,<0.2 resolves to the newest +cw 0.1.1 +demo 0.1.0 + +$ demo --help + ... + File ".../cw/cli.py", line 184, in set_default_command + parser.add_argument(*args, **kwargs) +argparse.ArgumentError: argument -p/--pool: conflicting option string: -p + ... +cw.grammar.GrammarError: serve: cannot add 'pool' as -p/--pool: argument -p/--pool: +conflicting option string: -p + +$ python -m cw.testing parity +[theremin] $ --help +returncode: + - 0 + + 1 +stderr: + + GrammarError: theremin_cli: cannot add 'log_knobs' as -l/--log-knobs: argument + -l/--log-knobs: conflicting option string: -l + ... +8 shapes / 133 cases: 36 DIFFER +exit=1 +``` + +**The rollback, one command:** + +``` +$ pip install --no-index --find-links ./wheels "cw==0.1.0" +cw 0.1.0 +demo 0.1.0 + +$ demo --help +usage: demo [-h] [--host HOST] [--port PORT] [--pool POOL] + +Serve something. + +options: + -h, --help show this help message and exit + --host HOST '0.0.0.0' + --port PORT 8080 + --pool POOL - + +$ python -m cw.testing parity +8 shapes / 133 cases: identical +exit=0 +``` + +Three things the drill established that a written procedure would not have: + +1. **The failure is loud, at startup, in every affected repo simultaneously** — a + `GrammarError` from `parser.add_argument`, before any command runs. It is not a silent + change of behaviour. That is a considerable comfort and it is a property of *this* + regression, not a guarantee about every possible one. +2. **`python -m cw.testing parity` diagnoses it from the installed package**, in a venv + containing cw and nothing else — no argh, no source checkout. It is the first command to + run when a fleet repo's CLI misbehaves after an upgrade, and it names the shape and the + case. +3. **The rollback needs no coordination.** `pip install "cw=="` in the affected + environment; the consumer's own pin is untouched. + +**The escalation ladder, in order:** + +| step | action | scope | +|---|---|---| +| 1 | `pip install "cw=="` in the broken environment | one env, seconds | +| 2 | `python -m cw.testing parity` to confirm the diagnosis | one env | +| 3 | Pin the *consumer* — `cw>=0.1,<0.2,!=` — if the repo redeploys before the fix | one repo | +| 4 | Yank the bad version on PyPI (Thor) | fleet | +| 5 | Fix in cw, with a corpus case that goes red without the fix, and release the next patch | fleet | +| 6 | Unpin step 3 | one repo | + +Step 3 is a **consumer-side `!=`**, not a fleet-wide re-pin: a scripted fleet action that +edits 34 `pyproject.toml` files is a bigger, less reversible event than the incident it +responds to. Only a repo that actually redeploys during the window needs it. + +### 6. Pre-release channel: waves, not release candidates + +Repos do **not** migrate against an rc tag. The pre-release channel is the migration wave +order itself: + +- **Wave 0** is the repos whose CLI behaviour is covered by their own tests and whose + maintainer is the person releasing cw. They migrate first, and their CI is cw's real + pre-release signal. +- **Wave 1 and later** migrate only after Wave 0 has been green through at least one cw + release. + +A repo that needs to test an unreleased cw pins the git ref for the duration +(`cw @ git+https://github.com/i2mint/cw@`) and returns to `cw>=0.1,<0.2` before its own +merge. A merge that must not publish carries `[skip ci]` in the merge commit — which +suppresses the whole workflow, tests included, so it is for documentation-only merges, not +for holding back a code change. + +Rejecting rc tags is a judgement about this fleet, not about rc's in general: with +"whatever lands" as the release cadence and one person releasing, an rc adds a step whose +only signal — "does the fleet still work" — is exactly what Wave 0's CI already reports, +and it burns a version number to get it. + +## Consequences + +- `cw>=0.1,<0.2` in ~34 repos means a `0.2` is a deliberate, coordinated fleet event. That + is the intended cost: a grammar change should be hard. +- The committed goldens are now a release contract, and reviewing a golden diff is part of + reviewing a cw PR. `cw/tests/README.md` states the per-shape counts and a test asserts the + README matches, so a silently added or removed case is caught too. +- cw's CI must stay green on all three OSes for the publish step to run. That is already the + configuration (`test_on_windows = true`). +- The drill's throwaway artefacts are not committed. Re-running it takes about two minutes: + build two wheels from this tree with the collision check removed from + `cw/grammar.py:_flag_spellings` in one of them, and follow the transcript. +- The `ARGH`-is-frozen rule constrains future work in a way worth stating plainly: a + genuinely better default cannot be shipped by improving `ARGH`. It ships as `MODERN`, and + a repo opts in. That is the deal D2 made and this ADR is where it becomes binding on + releases rather than only on code. + +## Alternatives considered + +- **Bare `cw` in consumers.** Simplest, and turns every cw release into an unbounded fleet + experiment. Rejected. +- **Exact pins (`cw==0.1.3`) fleet-wide.** Maximum containment, and it makes every cw patch + a 34-repo pull request. Rejected: the cure costs more than the disease, every time. +- **`ARGH` may be fixed in patch releases when it diverges from argh.** Kept, deliberately — + that is rule 2's first bullet, and it is the only kind of `ARGH` change allowed. What is + rejected is *improving* `ARGH`. +- **Publish release candidates before each minor.** Rejected for this fleet; see rule 6. +- **A scripted fleet re-pin as the standard incident response.** Rejected as step 3; it is + a larger, slower and less reversible action than the per-environment rollback that + actually fixes the outage. diff --git a/docs/adr/0006-the-v1-cut-list.md b/docs/adr/0006-the-v1-cut-list.md new file mode 100644 index 0000000..bb155b8 --- /dev/null +++ b/docs/adr/0006-the-v1-cut-list.md @@ -0,0 +1,166 @@ +# ADR-0006: The v1 cut list + +- **Status:** accepted +- **Date:** 2026-08-30 +- **Deciders:** Thor Whalen +- **Issue:** [#13](https://github.com/i2mint/cw/issues/13) +- **Depends on:** [ADR-0001](0001-the-v1-seam-table.md) + +## Context + +`architecture-first`'s test 1: **evidence, not naming.** A seam or a feature with no caller +is speculative generality, and naming a plausible consumer proves nothing — the consumer has +to exist somewhere you can point at. + +The canonical spec contained a number of features whose only stated consumer turned out, on +inspection, not to be a cw consumer at all. This ADR is the audit: what v1 ships, what it +does not, and — for each cut — **the boundary that already exists, where it comes back**. + +A cut is cheap only if its re-entry point is named. A cut with no named re-entry is a +decision someone will relitigate from scratch in six months. + +## Decision + +### A. Cut, with the re-entry boundary named + +| cut | why | where it comes back | +|---|---|---| +| **`func_codec`** (spec §5.4) | A convenience constructor — `func_codec(get_func, /, *, parse=parse_ast_spec, passthrough=())` — with zero call sites. Its vocabulary belongs to `cw/resolution.py`, not to any module that shipped. The `Codec` it would have built is spellable in one line by hand. | A function in `cw.resolution`, returning a `Codec`. The `config[param]['codec']` leaf it feeds already exists. | +| **`cw.compat.named`** | Verified zero fleet uses. Worse, as specified it was a **silent no-op**: it writes `func._cw['name']` and nothing reads it, so `@named('load')` on `do_load` would still register `do-load`. A shim that quietly does the wrong thing is worse than the `AttributeError` it prevents. | `cw.compat.__getattr__` raises an informative error naming the cw spelling: a mapping key, `{'load': do_load}` — which also lets two commands share a name in different groups. | +| **`cw.compat.aliases`** | Verified zero fleet uses. cw has no v1 equivalent. | Same `__getattr__`. A mapping can name the same callable twice if a repo needs it. | +| **`cw.compat.add_subcommands`** | Verified zero fleet uses. It is `add_commands(..., group_name=...)` with the arguments reordered. | Same `__getattr__`, which names the working call. | +| **`Convention.formatter_class`** | Duplicated by `**parser_kwargs` — two homes for one setting and no stated precedence. | It is already there: `cw.dispatch(..., formatter_class=argparse.ArgumentDefaultsHelpFormatter)`, where argparse names it. (Consequence: `cw/convention.py` does not import `argparse`, which is what makes §12's mechanical import check honest.) | +| **`Convention.completion`** | A field that does nothing does not ship, and the spec had completion "called automatically by `mk_parser` when `convention.completion` allows" while `Convention` had no such field. | Completion is a plain `completion: bool = True` parameter on `run` and `dispatch`, plus the public `cw.enable_completion(parser)`. Adding a convention field later is one line, if a *context* ever wants to disable completion rather than a *call*. | +| **`mk_ingress` as a facade name** | Its justification was "an i2 `Ingress` is a drop-in substitute", for a package that just spent an ADR refusing to import i2. Nobody asked for it as a public name. | It exists and is documented as `cw.ingress.mk_ingress`, honouring i2's `{name: value} -> (args, kwargs)` contract, and is simply not in `cw.__all__`. Promoting it is one line. | + +### B. Not cut, and why the cut list's reasoning did not survive contact + +Issue #13 proposed four further cuts. Each was re-examined against the code that now exists, +and each turned out to have a real caller. Recorded here so the question is closed rather +than reopened: + +**`json_egress` — kept.** The cut list's objection was "its pointer is `t/py2mcp`, which is +explicitly not a cw consumer". True, and incomplete: `t/xa/xa/cli.py:777` hand-rolls +`print(json.dumps(out, indent=2, default=str))` *inside a command body*, and xa's migration +replaces exactly that line with `egress=cw.json_egress`. That is a cw call site in the +migration plan, which is the evidence test 1 asks for. Twelve statements, fully covered. + +**`import_object` / the `'pkg.mod:name'` string form of `obj` — kept.** It has a caller +inside cw itself: `python -m cw specs 'pkg.mod:func'`, which answers *"what flags does this +function get, and why did that one not get a short flag"* without importing anybody's +`__main__`. That command is the most useful thing cw's own CLI does. The spec's unresolved +ambiguity is also resolved rather than inherited: **a string is always exactly one command, +with no command word.** §8.2 says "a bare string is ALWAYS one command" and §8.1's callable +row says "one command; no command word"; a string resolves to a callable, so the two rows +compose. `mk_parser` resolves the string before the single-command check. + +**Core `cw.add_commands` — kept.** Two callers, both real. `cw.compat.add_commands` +(15 measured fleet call sites) has to be three statements or fewer, which means the real +implementation lives in the core. And the parity corpus's `xa` shape uses +`mk_parser` + `add_commands(group_kwargs=...)` + `run` — the **only** path that exercises +ADR-0004 rule 1's `add_parser(help=group_kwargs.get('title'))` code path at all. Cutting it +from the core would mean rewriting that shape and losing the coverage. + +**The Site-B `Codec` protocol — kept, in part.** The cut list's case was that its only named +consumer (`t/theremin`) does not need it: theremin's four +`partial(resolve_to_function, ...)` are at `script_utils.py:236-241`, *inside* `run_theremin`, +never at the parser boundary — errata E12, and a prototype reproduced theremin 19/19 with no +codec at all. That is correct and it is why `func_codec` is cut above. + +What is kept is the mechanism, not the convenience layer: `cw.Codec` (a two-field value: +`decode`, `passthrough`), the `ArgSpec.codec` field, the `config[param]['codec']` leaf, and +`mk_ingress(codecs=...)`. Three reasons: + +1. The re-entry point the cut list named — *"`config[param]['codec']`, a new leaf key on a + dict that already exists"* — **is** this mechanism. Cutting `Codec` and keeping the leaf + is not a smaller thing; it is the same thing without a name. +2. The grammar already promotes a bare callable in a `config` leaf to a `Codec`. Cutting it + now is a three-module change, and leaving `ArgSpec.codec` in place while `mk_ingress` + ignored it would ship a dead field — the exact defect this ADR exists to prevent. +3. It closes a real hole that argparse's `type=` cannot: argparse applies `type=` to a + string `default` and to a `const` too, so a decoder that resolves names to objects would + be handed theremin's `'list'` sentinel and its defaults as well as its real arguments. + `passthrough` is what lets the sentinel survive, and + `tests/test_fleet_shapes.py::test_the_sentinel_survives_a_codec` demonstrates the case. + +### C. Not built at all in v1 — the standing list + +Neither cut nor deferred; simply out of scope, with the shape of the eventual addition +recorded so nobody designs it twice. + +1. **Docstring-derived per-parameter help.** argh's `help='%(default)s'` wart means an + undocumented flag's help column reads `False` or `'json'`. The replacement exists — + `i2.doc_mint.docstring_to_params` (`doc_mint.py:405`) parses numpy/google/rest — and + `t/an/an/__main__.py:76-106` is the fleet convention it would restore. Not in v1 because + it is not implemented, and a `Convention` field that does nothing does not ship. When it + arrives it is `cw[docs]`, imported lazily inside a function: the one place i2 earns its + way back in. **It is also what unblocks `MODERN.default_in_help=False`** (ADR-0004 + rule 4). +2. **Lazy command loading.** `priv/__init__.py` is a careful PEP-562 lazy design that + `__main__.py` defeats by `getattr`-ing 47 names. cw does not fix it: argparse must have + *every* subparser registered to print `--help` and to let argcomplete complete. A pre-scan + is ~15 lines and makes `priv --help` incomplete — breaking the one feature seven fleet + repos depend on. If it is ever wanted, the spelling is a lazy group value, and nothing in + the current rules changes. +3. **A surface-neutral `CommandSpec` / non-CLI adapters.** No consumer. `py2mcp` and `qh` + consume plain functions by string ref today. See ADR-0001's `Surface for v1:` line. +4. **`**kwargs` collection from the command line.** No agreed spelling (`--set k=v`? bare + `k=v`? repeated `--opt`?) and the only candidate consumer, `wads.populate_pkg_dir`'s + `**configs`, is content to have them dropped — which is argh's behaviour and therefore + cw's. Picking a spelling without a consumer is speculative. +5. **Async.** Zero fleet occurrences. v1 does exactly one thing: `guard_no_coroutine` raises + an informative `TypeError` naming `asyncio.run`, **at the call site in `run`**, so no + choice of egress can switch it off. This is a deliberate, documented divergence — argh's + real behaviour is to print `` and warn, having never run the body, + and reproducing *that* is not what D2 means by fidelity. +6. **Windows-specific handling.** cw inherits argparse's platform behaviour and adds + nothing: `'-'`→stdin, EPIPE on a closed pipe, and colour detection are absent, as they + are in argh. `cw.testing`'s `shlex.split` is POSIX-only; a golden may spell its argv as a + JSON list instead, which is the documented Windows path. +7. **`cw.bind` / `set_dispatch_defaults` / a `Param` class / a `Codec` registry / a `name_of=` + seam.** All named in candidate proposals; all replaced by, respectively, + `functools.partial(cw.dispatch, convention=..., prog=...)`, plain dicts of `add_argument` + kwargs, fall-through composition, and the `{name: func}` mapping form. + +### D. `mk_parser` is pure; completion fires at dispatch time + +The spec specified `mk_parser` as pure — *"no parsing, no I/O, no side effects"* — **and** as +firing `argcomplete.autocomplete()`, which reads the environment and can `exit()` the +process, while also selling `mk_parser` as the thing a test inspects. Those cannot all hold. + +**Decision: completion fires at dispatch time**, which is where argh fires it. +`cw.enable_completion` does its `import argcomplete` *inside the function* (so `import cw` +stays stdlib-only, enforced by `tests/test_import_is_cheap.py`), and `run` calls it before +parsing. `mk_parser` performs no I/O, imports nothing third-party, and cannot exit the +process; a test asserts it. + +## Consequences + +- The public API listing in the README is exactly `cw.__all__`, and `cw.__all__` is exactly + what survived this ADR. A name in one and not the other is a bug in whichever is newer. +- `cw.compat`'s `__getattr__` means a repo that used `argh.named` gets a message naming the + cw spelling instead of a traceback that names nothing. The three unshipped names are the + only place cw's compat layer refuses rather than translates. +- Section B closes four questions that three previous phases each flagged as open. They are + closed *with* their evidence, so reopening one means finding a caller that does not exist + rather than re-reading the same spec paragraph. +- Section C's items are additions at boundaries that already exist. None of them requires + a core change, which is the claim ADR-0001 makes and this section is the audit of. +- One cost worth stating: keeping `Codec` means cw ships a mechanism whose only demonstrated + case is a test fixture. That is the weakest "kept" decision in this ADR, and if a year + passes with no `config[param]['codec']` in the fleet, it is the first thing to cut. + +## Alternatives considered + +- **Cut everything issue #13 listed, on principle.** Rejected: four of the seven had callers + the issue had not seen, and cutting `add_commands` would have removed the only path that + exercises ADR-0004 rule 1. +- **Keep `named` as specified** (write `func._cw['name']`, read it in `cw.commands`). That + is a feature, not a shim, and it duplicates the mapping-key rule ADR-0004 rule 2 just + settled. Two ways to name a command is not compatibility. +- **Ship `func_codec` because `Codec` survived.** Rejected: `Codec` earns its place as the + mechanism behind a documented leaf key; `func_codec` is a convenience with no call site, + and it is one line to write by hand when someone needs it. +- **Fire completion in `mk_parser`** (the spec's literal reading). Rejected: it makes the + build step read the environment and possibly exit, which breaks the thing `mk_parser` is + for — being inspected by a test. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..1d65f08 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,17 @@ +# Architecture decision records + +Six decisions, written while cw v1 was built, in the Nygard format used across the fleet +(`docs/adr/NNNN-slug.md`, immutable once accepted; change one by writing a new ADR that +supersedes it, never by editing an accepted **Decision** section in place). + +| ADR | Decides | Issue | +|---|---|---| +| [0001](0001-the-v1-seam-table.md) | The v1 seam table: three seams, their defaults, and the `NOT seams:` line | [#8](https://github.com/i2mint/cw/issues/8) | +| [0002](0002-the-ingress-stash.md) | How a **plain** `ArgumentParser` carries convention, config and ingress into `run` | [#9](https://github.com/i2mint/cw/issues/9) | +| [0003](0003-the-merge-ladder.md) | The four-tier merge ladder is argh's field-specific merge, not `dict.update` | [#10](https://github.com/i2mint/cw/issues/10) | +| [0004](0004-grammar-errata.md) | Grammar errata: `group_kwargs`, Mapping-key naming, MODERN's help column | [#11](https://github.com/i2mint/cw/issues/11) | +| [0005](0005-release-and-rollback-policy.md) | Release, pinning and rollback policy for the ~34 repos that will depend on cw | [#12](https://github.com/i2mint/cw/issues/12) | +| [0006](0006-the-v1-cut-list.md) | The v1 cut list: what cw deliberately does **not** ship, and where each comes back | [#13](https://github.com/i2mint/cw/issues/13) | + +Read them in order. 0001 is the one that must outlive the session: it is the budget every +later addition is spent against. diff --git a/pyproject.toml b/pyproject.toml index d04be22..305314a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,50 @@ [build-system] +# >=1.27 for PEP 639 (`license` as an SPDX expression + `license-files`). requires = [ - "hatchling", + "hatchling>=1.27", ] build-backend = "hatchling.build" [project] name = "cw" version = "0.0.15" -description = "Command line Wizard - Transform your python functions into CLI tools" +description = "Command line Wizard - turn your Python functions into a CLI" readme = "README.md" requires-python = ">=3.10" -keywords = [] -authors = [] +license = "MIT" +license-files = [ + "LICENSE", +] +keywords = [ + "argparse", + "argh", + "cli", + "command-line", + "console", +] +authors = [ + { name = "Thor Whalen" }, +] +# Deliberately empty, and asserted by tests/test_import_is_cheap.py: `import cw` is +# stdlib-only. i2 is an optional extra used only by cw.resolution.resource_inputs. dependencies = [] - -[project.license] -text = "MIT" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", +] [project.urls] Homepage = "https://github.com/i2mint/cw" +Issues = "https://github.com/i2mint/cw/issues" [project.optional-dependencies] resource = [ @@ -29,8 +55,14 @@ completion = [ ] # What CI installs. Deliberately NO argh: `python -m cw.testing parity` -- the definition # of v1 -- runs against committed goldens and must prove it needs nothing but cw. The -# `tests/argh_parity` differential skips itself when argh is absent. +# `tests/argh_parity` differential (and the two tests outside it that share its corpus) +# skip themselves when argh is absent. +# +# `cw[resource]` IS here, and that is not a contradiction: i2 is the house's own package, +# not the LGPL one cw exists to replace, and `cw.resolution.resource_inputs` is a shipped +# feature whose tests and doctests need it. Without this line CI silently skipped them. test = [ + "cw[resource]", "pytest>=7.0", "pytest-cov>=4.0", ] diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..b43c7e6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +"""cw's test suite. + +A package rather than a bare directory so that the live-argh differential in +:mod:`tests.argh_parity` can be imported by name (``from tests.argh_parity import +corpus``) from any working directory. +""" diff --git a/tests/argh_parity/__init__.py b/tests/argh_parity/__init__.py index e69de29..9023ac2 100644 --- a/tests/argh_parity/__init__.py +++ b/tests/argh_parity/__init__.py @@ -0,0 +1,10 @@ +"""The live differential against argh 0.31.3 -- developer-only. + +Every module here builds the *same* function's parser twice, once with argh and once +with cw, and asserts the two are identical. It therefore needs argh installed, which +only ``pip install 'cw[dev]'`` provides; ``conftest.py`` skips the whole package when +argh is absent, so ``cw[test]`` (what CI installs) stays green without it. + +This is the only place in the repo that imports argh, and nothing under ``cw/`` ever +does -- argh is LGPL-3.0-or-later and cw is MIT. +""" diff --git a/tests/argh_parity/test_cli_parity.py b/tests/argh_parity/test_cli_parity.py index 3870bbe..432c861 100644 --- a/tests/argh_parity/test_cli_parity.py +++ b/tests/argh_parity/test_cli_parity.py @@ -13,8 +13,6 @@ argh is a test-only dependency. Nothing under `cw/` imports it. """ -import contextlib -import io import os import argh @@ -22,6 +20,8 @@ import cw +from tests.capture import capture + os.environ.setdefault("COLUMNS", "100") @@ -158,15 +158,8 @@ def command(): # ---------------------------------------------------------------------------- running -def _capture(call): - """`call(out, err) -> code`, with argparse's own output captured into the buffers.""" - out, err = io.StringIO(), io.StringIO() - with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): - try: - code = call(out, err) - except SystemExit as exc: - code = exc.code - return code, out.getvalue(), err.getvalue() +#: Defined in `tests/capture.py` so that importing it does not drag in argh. +_capture = capture def run_argh(build, argv): diff --git a/tests/capture.py b/tests/capture.py new file mode 100644 index 0000000..e430ce1 --- /dev/null +++ b/tests/capture.py @@ -0,0 +1,36 @@ +"""Run a CLI call and collect ``(exit code, stdout, stderr)`` -- stdlib only. + +Lives in its own module so that a test needing it does **not** thereby need ``argh``. +It used to be a private helper inside ``tests/argh_parity/test_cli_parity.py``, which +made every importer inherit that package's argh requirement; ``tests/test_fleet_shapes.py`` +imported it for assertions that are cw's own, and so failed to collect under +``pip install -e '.[test]'`` -- the extra cw's CI actually installs. +""" + +import contextlib +import io + + +def capture(call): + """``call(out, err) -> code``, with argparse's own output captured into the buffers. + + Both halves matter. ``out``/``err`` are handed to the call because that is how cw is + told where to write; the ``redirect_*`` wrappers catch what argparse prints on its own + account (``--help``, ``usage:``, ``error:``), which it sends to the real streams. + + >>> capture(lambda out, err: print('hi', file=out) or 0) + (0, 'hi\\n', '') + + A ``SystemExit`` is an answer, not a crash -- it is what argparse raises on a usage + error, and its code is the process's exit code: + + >>> capture(lambda out, err: (_ for _ in ()).throw(SystemExit(2))) + (2, '', '') + """ + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + code = call(out, err) + except SystemExit as exc: + code = exc.code + return code, out.getvalue(), err.getvalue() diff --git a/tests/test_cli.py b/tests/test_cli.py index 831778c..5270184 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -305,11 +305,11 @@ def test_two_add_commands_calls_share_one_subparsers_action(self): class TestConfigKeysAreChecked: def test_an_unknown_command_key_is_a_hard_error(self): - with pytest.raises(GrammarError, match="match no command"): + with pytest.raises(GrammarError, match="matche?s? no command"): cw.mk_parser({"echo": echo}, config={"ehco": {"word": {"help": "h"}}}) def test_an_unknown_group_key_is_a_hard_error(self): - with pytest.raises(GrammarError, match="match no command"): + with pytest.raises(GrammarError, match="matche?s? no command"): cw.mk_parser( {"grp": {"echo": echo}}, config={"grp": {"ehco": {"word": {"help": "h"}}}}, diff --git a/tests/test_convention.py b/tests/test_convention.py index 0317fb5..f318b77 100644 --- a/tests/test_convention.py +++ b/tests/test_convention.py @@ -1,4 +1,4 @@ -"""`cw.convention`: two values, ten switches, and no field that does nothing. +"""`cw.convention`: two values, nine fields, and no field that does nothing. `Convention` is the third seam, and the rule it lives by is that a field which changes nothing does not ship. So every field gets a test that flips it and watches the parser diff --git a/tests/test_corpus_coverage.py b/tests/test_corpus_coverage.py index ba4729c..400f2c1 100644 --- a/tests/test_corpus_coverage.py +++ b/tests/test_corpus_coverage.py @@ -247,7 +247,11 @@ def test_nothing_under_cw_imports_argh(self): def test_argh_is_not_a_runtime_dependency_nor_in_the_test_extra(self): """Issue #7: CI installs `cw[test]`, and `cw[test]` must not pull LGPL argh.""" import pathlib - import tomllib + + # `tomllib` is stdlib only from 3.11, and cw's CI matrix includes 3.10. The + # assertion is about a file, not about an interpreter, so running it on the + # other leg is enough. + tomllib = pytest.importorskip("tomllib", reason="stdlib tomllib needs 3.11+") root = pathlib.Path(testing.__file__).parent.parent config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py new file mode 100644 index 0000000..1d921d3 --- /dev/null +++ b/tests/test_docs_examples.py @@ -0,0 +1,97 @@ +"""Every ``>>>`` example in README.md and docs/adr/*.md must actually run. + +The README is the package's front door and the ADRs are its permanent record, so an +example that has drifted from the code is worse than no example: a reader copies it. +``--doctest-modules`` only reaches Python modules, so these files would otherwise be +the one place in the repo where a claim is never checked. + +Two mechanics are worth knowing before adding an example to those files: + +- A fence line (```` ``` ````) is blanked before parsing. doctest ends an example's + expected output at a blank line, so without this the closing fence reads as part of + the expected output and every block "fails". +- Each file gets a *fresh* namespace pre-loaded with the names its prose defines in + ```` ```python ```` fences (which doctest cannot execute). Keep that list in step + with the README, or an example that leans on one will fail here rather than silently + pass. +""" + +import doctest +import pathlib +import re + +import pytest + +import cw + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent + +DOC_FILES = [REPO_ROOT / "README.md"] + sorted( + (REPO_ROOT / "docs" / "adr").glob("*.md") +) + +FENCE = re.compile(r"(?m)^```.*$") + + +def _readme_globals(): + """The names the README's non-doctest ```python fences bring into scope.""" + + def greet(name, *, loudly=False): + """Say hello to someone.""" + return f"HELLO {name}" if loudly else f"hello {name}" + + def add(a: int, b: int): + """Add two numbers.""" + return a + b + + def ls(path=".", *, long=False): + """List a directory.""" + return [f"{path}/one", f"{path}/two"] + + return { + "cw": cw, + "greet": greet, + "add": add, + "ls": ls, + "COMMANDS": {"add": add, "list": ls, "git-ops": {"add": add}}, + } + + +@pytest.mark.parametrize( + "path", DOC_FILES, ids=lambda p: p.relative_to(REPO_ROOT).as_posix() +) +def test_every_documented_example_runs(path): + text = FENCE.sub("", path.read_text()) + globs = _readme_globals() + runner = doctest.DocTestRunner( + optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS + ) + name = str(path.relative_to(REPO_ROOT)) + test = doctest.DocTestParser().get_doctest(text, globs, name, name, 0) + runner.run(test, out=lambda s: None) + result = runner.summarize(verbose=False) + assert result.failed == 0, ( + f"{name}: {result.failed} of {result.attempted} examples failed. " + f"Run `python -m pytest tests/test_docs_examples.py -k {path.stem!r} -v` " + f"after re-running the block by hand to see the diff." + ) + + +def test_the_docs_actually_contain_examples(): + """A regex that silently stopped matching would make the test above vacuous.""" + counts = {} + for path in DOC_FILES: + text = FENCE.sub("", path.read_text()) + # Keyed by path, not by name: docs/adr/README.md would shadow the package's. + name = path.relative_to(REPO_ROOT).as_posix() + parsed = doctest.DocTestParser().get_doctest(text, {}, name, name, 0) + counts[name] = len(parsed.examples) + + assert counts["README.md"] >= 20, counts + # The three ADRs that carry transcripts. ADR-0001/0005/0006 are prose and tables. + for adr in ( + "docs/adr/0002-the-ingress-stash.md", + "docs/adr/0003-the-merge-ladder.md", + "docs/adr/0004-grammar-errata.md", + ): + assert counts[adr] > 0, f"{adr} lost its examples: {counts}" diff --git a/tests/test_fleet_shapes.py b/tests/test_fleet_shapes.py index 95e9c73..5c369fb 100644 --- a/tests/test_fleet_shapes.py +++ b/tests/test_fleet_shapes.py @@ -16,12 +16,24 @@ import functools import io -import argh import pytest import cw -from tests.argh_parity.test_cli_parity import _capture, run_argh +from tests.capture import capture as _capture + +# argh is a **dev** extra; CI installs `cw[test]`, which has none. Only the one test +# below that actually diffs against argh needs it, so the module imports without it and +# that test skips itself -- everything else here asserts cw's own behaviour and must run +# in the environment CI really uses. +try: + import argh +except ImportError: # pragma: no cover -- exercised by the `cw[test]` CI leg + argh = None + +requires_argh = pytest.mark.skipif( + argh is None, reason="the live differential needs argh: pip install -e '.[dev]'" +) # ------------------------------------------------------------------ t/priv, shape only @@ -121,10 +133,15 @@ def test_without_the_hide_line_the_leak_is_warned_about(self): def _declare(*flags, **kwargs): - """`@argh.arg` and cw's `func._cw` from one call, so the two cannot drift.""" + """`@argh.arg` and cw's `func._cw` from one call, so the two cannot drift. + + When argh is absent only cw's half is written, which is what the two cw-only + assertions below need; the differential that needs both is skipped. + """ def decorate(func): - func = argh.arg(*flags, **kwargs)(func) + if argh is not None: + func = argh.arg(*flags, **kwargs)(func) name = (flags[-1] if len(flags) > 1 else flags[0]).lstrip("-").replace("-", "_") params = dict(getattr(func, "_cw", {}).get("params", {})) func._cw = {"params": {name: dict(kwargs, flags=list(flags)), **params}} @@ -155,6 +172,7 @@ def theremin_cli(pipeline="theremin", synth="sine", scale=None, seconds=10): ] +@requires_argh @pytest.mark.parametrize("argv", THEREMIN_ARGVS, ids=lambda a: " ".join(a) or "(none)") def test_theremin_matches_argh(argv): """Including the two things that look like special cases and are neither. @@ -163,6 +181,9 @@ def test_theremin_matches_argh(argv): *appended* to the inferred ones, and `--scale` gets no short flag at all because `synth`, `scale` and `seconds` all start with `s`. No line of cw knows about either. """ + # Imported here, not at module scope: that module needs argh at import time. + from tests.argh_parity.test_cli_parity import run_argh + left = run_argh(lambda p: p.set_default_command(theremin_cli), argv) right = _capture( lambda out, err: cw.dispatch( diff --git a/tests/test_grammar.py b/tests/test_grammar.py index 0e28a73..aa70742 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -72,6 +72,10 @@ def test_collision_suppression_at_scale(): about the whole signature: adding one parameter can silently take another's short flag away, which is why cw writes the rule as data rather than as a heuristic. """ + # The corpus is shared with the live differential, so it imports argh -- a dev extra. + pytest.importorskip( + "argh", reason="the shared corpus needs argh: pip install -e '.[dev]'" + ) from tests.argh_parity.corpus import many_parameters specs = specs_for_function(many_parameters) diff --git a/tests/test_testing.py b/tests/test_testing.py index 9692671..b6aef45 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -581,3 +581,68 @@ def closes_its_own_stdout(): return 0 assert testing.capture(closes_its_own_stdout)["returncode"] == 0 + + +class TestAGoldenReplaysOnAnyCPython: + """The gate's goldens were recorded on one interpreter and asserted on the matrix. + + cw's CI runs 3.10 and 3.12, and argparse's *own* rendering differs between them. Those + differences belong to CPython, not to cw -- argh and cw print the same bytes as each + other on any one interpreter -- so `canonical_argparse_text` neutralises exactly two of + them and nothing else. Without it `python -m cw.testing parity` is red on 3.10 with a + correct cw, which is the worst kind of gate: one that cries wolf. + """ + + def test_the_invalid_choice_quoting_change_is_forgiven(self): + """argparse quoted its choices up to 3.11 and stopped in 3.12.""" + upto_311 = "x: error: invalid choice: 'q' (choose from 'a', 'b')" + since_312 = "x: error: invalid choice: 'q' (choose from a, b)" + assert testing.canonical_argparse_text(upto_311) == since_312 + assert testing.canonical_argparse_text(since_312) == since_312 + + def test_the_usage_block_rewrap_is_forgiven(self): + """3.13 stopped wrapping a trailing `...` onto its own line.""" + upto_312 = "usage: p [-h]\n {a,b}\n ...\n\nnext" + since_313 = "usage: p [-h] {a,b} ...\n\nnext" + assert testing.canonical_argparse_text(upto_312) == since_313 + assert testing.canonical_argparse_text(since_313) == since_313 + + def test_a_case_differing_only_by_interpreter_compares_equal(self): + recorded = { + "tier": 1, + "returncode": 2, + "stdout": "", + "stderr": "err: invalid choice: 'q' (choose from 'a', 'b')\n", + "usage": "", + } + fresh = dict(recorded, stderr="err: invalid choice: 'q' (choose from a, b)\n") + assert testing.compare_case(recorded, fresh) == "" + + def test_it_forgives_ONLY_those_two_and_still_sees_a_real_difference(self): + """The normalisation must not have quietly become `assert True`.""" + recorded = { + "tier": 1, + "returncode": 0, + "stdout": "hi\n", + "stderr": "", + "usage": "", + } + # a different choice set is a real grammar change, not a rendering difference + a = {"tier": 1, "returncode": 2, "stdout": "", "stderr": "", "usage": ""} + assert testing.compare_case(recorded, dict(recorded, stdout="ho\n")) != "" + assert testing.compare_case(recorded, dict(recorded, returncode=1)) != "" + assert ( + testing.compare_case( + dict(a, stderr="(choose from 'a', 'b')"), + dict(a, stderr="(choose from 'a', 'c')"), + ) + != "" + ) + # and the usage collapse must not swallow a missing option + assert ( + testing.compare_case( + dict(a, stdout="usage: p [-h] [-v]\n"), + dict(a, stdout="usage: p [-h]\n"), + ) + != "" + ) From b9a0d6d4dfa0af4f3fe2dce734d77d622b722f2a Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:38:28 +0200 Subject: [PATCH 6/7] Fix everything three adversarial reviews found, and the blind spots that hid it Two blockers, five majors and the minors worth fixing, plus the tests that should have caught each one. ADR-0007 records the four that were defects in a *decision* rather than in its implementation. Blockers - cw.compat.ArghParser never set formatter_class, so the advertised one-line migration changed --help for the 25 fleet files that hold a parser object: `-` became `None`, `'0.0.0.0'` lost its quotes, multi-paragraph docstrings reflowed. Fixed as argh does it, which is NOT what it looks like: ArghParser.__init__ defaults it, subparsers get it when the parent still carries argparse's stock one, and a plain parser's own formatter is left alone (promoting it there would have added a divergence while removing one -- the new differential caught that). - CI installed no extras, so three shipped cw.resolution items failed on a missing i2. [tool.wads.ci.install] extras = "test"; two false comments corrected. Majors - D2 violation: a hyphenated positional carrying `choices` printed `project-dir` where argh printed `{a,b}`, in usage: and in --help. argparse reads a positional's registered name twice and a synthesised metavar wins only the error-message reading. cw now registers the hyphenated name, as argh does, and renames the dest back in _call_args. Spec 9.3's "one permitted divergence" is retired and the harness forgives nothing. - Two commands deriving one name silently kept the last and ran the wrong function with exit 0. Now CommandTreeError naming both callables, as argh refuses it too. - @arg(..., completer=...) crashed with a raw argparse TypeError -- in the shim through which the ten # PYTHON_ARGCOMPLETE_OK repos migrate, for the sake of which cw is argparse-based at all. - add_argument failures caught only ArgumentError, so ValueError/TypeError escaped naming neither cw nor the function nor the parameter -- strictly worse than the argh being replaced, on `config=`, which is the newest surface. - `replay` said "identical" about a migration whose --help visibly changed: a formatter change moves only the help column and the description. It now compares the body through normalise_help (width-independent) and reports `help-differs`; --strict-help makes it fatal. Minors - @arg(..., dest=...) named a parameter that does not exist. - MODERN's Enum help advertised member reprs the converter rejects. - The bound-keyword UserWarning printed a hex id() and a '' placeholder, and its only remedy removed a flag argh exposed. Now BoundKeywordWarning, named per command, silenceable with CW_QUIET=1. - egress= on mk_parser blamed argparse; ADR-0001 gains the row saying where each seam lives. - CommandTreeError / IngressError / BoundKeywordWarning exported; resolve_object demoted from the facade (no call sites anywhere, uncovered, TODO'd). - README: the add_commands/ArghParser shape, the mapping-value-must-be-a-list trap, and two more import forms on the migration grep list. The corpus gains the shape both corpora were blind to (lacing convert-tree): a hyphenated positional with Literal choices. 8 shapes / 137 cases, and the gate goes red (3 DIFFER) on a reintroduction. tests/argh_parity/test_compat_parity.py is the missing differential -- it renders help through every compat entry point and diffs it against live argh. 868 passed, 2 skipped (dev, argh present); 574 passed on 3.12 and 573 on 3.10 under cw[test]; parity identical on 3.10/3.11/3.12/3.13; mutation battery 9/9 red. refs #25, #26 Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr --- README.md | 67 +++++- cw/__init__.py | 9 +- cw/cli.py | 177 ++++++++++++-- cw/commands.py | 46 +++- cw/compat.py | 56 ++++- cw/grammar.py | 80 +++++-- cw/testing.py | 127 +++++++++-- cw/tests/README.md | 8 +- cw/tests/fixtures.py | 29 ++- cw/tests/goldens/lacing.json | 53 ++++- docs/adr/0001-the-v1-seam-table.md | 23 +- docs/adr/0005-release-and-rollback-policy.md | 6 +- ...007-what-the-adversarial-review-changed.md | 145 ++++++++++++ docs/adr/README.md | 3 +- pyproject.toml | 15 +- tests/argh_parity/conftest.py | 3 +- tests/argh_parity/corpus.py | 22 ++ tests/argh_parity/harness.py | 30 ++- tests/argh_parity/test_compat_parity.py | 215 ++++++++++++++++++ tests/argh_parity/test_parity.py | 31 ++- tests/test_cli.py | 190 +++++++++++++++- tests/test_commands.py | 72 ++++++ tests/test_compat.py | 31 +++ tests/test_convention.py | 56 +++++ tests/test_grammar.py | 17 +- tests/test_resolution.py | 15 +- tests/test_testing.py | 43 +++- 27 files changed, 1444 insertions(+), 125 deletions(-) create mode 100644 docs/adr/0007-what-the-adversarial-review-changed.md create mode 100644 tests/argh_parity/test_compat_parity.py diff --git a/README.md b/README.md index 053d97e..bb3c105 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,46 @@ hi 0 ``` +**Grow a parser one group at a time.** The shape `t/priv` and `i/wads` use — a parser +object, then repeated `add_commands` — is `cw.add_commands`, and it is the only way to pass +per-group `add_subparsers` keywords such as `title`: + +```python +>>> parser = cw.mk_parser([], prog='priv') +>>> def status(): "Say how things are." +>>> _ = cw.add_commands(parser, [status], group_name='git_ops', +... group_kwargs={'title': 'Git operations'}) +>>> cw.run(parser, ['git_ops', 'status']) +0 +``` + +`cw.mk_parser([], prog=...)` is the empty-parser seed. A plain `argparse.ArgumentParser()` +works too and renders identically — `add_commands` gives every subparser +`cw.ArghHelpFormatter` when the parent still carries argparse's stock one, which is exactly +what argh does — but then the *root* parser's own `--help` uses argparse's formatter, again +exactly as under argh. Pass `formatter_class=cw.ArghHelpFormatter` yourself if you want the +root to match too. + +`dispatch({'archive': {...}})` has no channel for `group_kwargs`; a group declared that way +gets an empty listing row where argh printed its `title`. Use `mk_parser` + `add_commands` + +`run` when you need one ([#31](https://github.com/i2mint/cw/issues/31)). + +**A mapping value must be the commands, not the factory that returns them.** A callable +value is always a *command*, because there is no way to tell a zero-argument factory from a +command that takes no arguments — so write the parentheses: + +```python +>>> def dispatch_funcs(): +... return [status] +>>> list(cw.commands_from({'git_ops': dispatch_funcs()})) # a GROUP +['git_ops'] +>>> list(cw.commands_from({'git_ops': dispatch_funcs})) # a COMMAND +['git-ops'] +``` + +Note the hyphen in the second one: `{'git_ops': dispatch_funcs}` — no parentheses — gives +you a *command* called `git-ops` that prints the reprs of the group's members, exit 0. + **Capture the output.** `out=` and `err=` are plain parameters resolved at *call* time, so a test can pass a `StringIO` — something an `argh` CLI cannot do, because argh binds `output_file=sys.stdout` in a signature default: @@ -340,11 +380,17 @@ decided to live here for a while. Declare the dependency as: dependencies = ["cw>=0.1,<0.2"] ``` -**Before you do it, grep for `from argh import CommandError`.** A module that imported the -*name* rather than the module still imports argh after the one-line change, and cw will not -catch an exception class it has never heard of — `CommandError: boom` / exit 1 becomes an -unhandled traceback. The shim structurally cannot fix this one; it is the single -highest-value line on the checklist. +**Grep for three import forms before you do it.** The one-line change rewrites `import +argh`; it cannot rewrite a name or a submodule somebody imported directly. + +| grep for | why it breaks | write instead | +|---|---|---| +| `from argh import CommandError` | the module still imports argh, and cw will not catch an exception class it has never heard of — `CommandError: boom` / exit 1 becomes an unhandled traceback. **The shim structurally cannot fix this one**; it is the single highest-value line on the checklist. | `from cw import CommandError` | +| `from argh.assembling import NameMappingPolicy` | `cw.compat` is a module, not a package, so there is no `cw.compat.assembling` | `from cw.compat import NameMappingPolicy` | +| `argh.interaction.confirm` | same reason — there is no `interaction` namespace | `argh.confirm` (i.e. `cw.confirm`) | + +Each of the last two raises an `AttributeError` naming the replacement, so a missed one is a +startup failure rather than a silent change. **Step 2 — delete the compat import.** `dispatch_commands(funcs)` becomes `cw.dispatch(funcs)`; `@argh.arg(...)` decorators become one `config` dict; `argh`'s @@ -358,8 +404,17 @@ and replays it after: python -m cw.testing characterize 'mytool' --cases ./cases.txt -o before.json # on the new python -m cw.testing replay before.json --prog 'mytool' +# ... and, when the migration promised --help would not move: +python -m cw.testing replay before.json --prog 'mytool' --strict-help +python -m cw.testing diff-help before.json --prog 'mytool' # read it, do not assert it ``` +`replay` asserts the exit code and both streams in full for every non-`--help` case, and the +normalised `usage:` line for a `--help` one. A `--help` **body** that moved is reported as +the non-fatal `help-differs` — never as `identical` — because a change of *formatter* moves +only the help column and the description block and would otherwise be invisible. +`--strict-help` makes it fatal; `diff-help` prints it for a human. + The recording half **imports no `cw`** — it is one file you can copy into a repo that will never depend on cw, which is most of them. @@ -376,7 +431,7 @@ Two things to expect, both argh's rules that cw reproduces: ```bash python -m cw specs 'mypkg.cli:main' # what flags would this function get, and why? python -m cw help 'mypkg.cli:main' # the --help cw would print for it -python -m cw parity # cw's own migration gate, 8 shapes / 133 cases +python -m cw parity # cw's own migration gate, 8 shapes / 137 cases ``` `specs` is the one that earns its place day to day — it answers *"why did that parameter not diff --git a/cw/__init__.py b/cw/__init__.py index e1f7f84..1390495 100644 --- a/cw/__init__.py +++ b/cw/__init__.py @@ -56,6 +56,7 @@ MISSING, ) from cw.cli import ( + BoundKeywordWarning, add_commands, dispatch, enable_completion, @@ -63,7 +64,7 @@ run, set_default_command, ) -from cw.commands import commands_from +from cw.commands import CommandTreeError, commands_from from cw.convention import ( ARGH, BY_NAME_IF_HAS_DEFAULT, @@ -85,12 +86,12 @@ command_name, modern_decode, ) +from cw.ingress import IngressError from cw.resolution import ( parse_ast_spec, parse_json_spec, parse_spec_with_dot_path, resolve_func_from_dot_path, - resolve_object, resolve_to_function, resource_inputs, ) @@ -105,6 +106,7 @@ "HIDE", "MISSING", # -- cli: building a parser, and running one ---------------------------------------- + "BoundKeywordWarning", "add_commands", "dispatch", "enable_completion", @@ -112,9 +114,11 @@ "run", "set_default_command", # -- commands: an object becomes a {name: callable} tree ----------------------------- + "CommandTreeError", "commands_from", # -- grammar: a signature becomes command-line arguments ---------------------------- "GrammarError", + "IngressError", "argh_decode", "cli_name", "command_name", @@ -136,7 +140,6 @@ "parse_json_spec", "parse_spec_with_dot_path", "resolve_func_from_dot_path", - "resolve_object", "resolve_to_function", "resource_inputs", ] diff --git a/cw/cli.py b/cw/cli.py index 139654f..89af9fb 100644 --- a/cw/cli.py +++ b/cw/cli.py @@ -39,6 +39,7 @@ import dataclasses import functools import inspect +import os import sys import warnings from collections.abc import Mapping @@ -58,9 +59,21 @@ "add_commands", "set_default_command", "enable_completion", + "BoundKeywordWarning", "RESERVED_DEST", ] + +class BoundKeywordWarning(UserWarning): + """A ``functools.partial``'s pre-bound keyword is still exposed as a CLI option. + + Its own category so that a repo which has decided the flag is fine can silence exactly + this warning without changing its CLI, and without silencing anything else:: + + warnings.filterwarnings('ignore', category=cw.BoundKeywordWarning) + """ + + #: The one ``set_defaults`` key cw reserves on the parsers it builds. Everything a built #: parser needs to tell :func:`run` -- which function this subcommand is, how to call it, #: and under which convention -- travels in it. @@ -70,6 +83,30 @@ #: reports when it catches one. USAGE_ERROR_CODE = 2 +#: cw keywords that are real, but not on *this* call. Without this, ``mk_parser(f, +#: egress=...)`` reports that ``argparse.ArgumentParser`` has no such keyword and lists +#: argparse's parameters -- true, and the least useful true thing to say. A seam is one +#: keyword argument, but not every seam is on every entry point: ``egress`` runs after the +#: call, so it belongs to :func:`run` and :func:`dispatch`, and ``decode`` shapes the +#: parser, so it belongs to :func:`mk_parser`, :func:`dispatch` and :func:`add_commands`. +SEAMS_ELSEWHERE = { + "egress": ( + "cw's egress seam turns a return value into output, which happens when a command " + "runs -- so it is a keyword of cw.run and cw.dispatch, not of cw.mk_parser. To " + "bind it to a parser instead, put it on the convention: " + "convention=dataclasses.replace(cw.ARGH, egress=my_egress)." + ), + "ingress": ( + "cw has no `ingress=` keyword. The namespace-to-call step is built from the " + "function's own signature (cw.ingress.mk_ingress); per-parameter conversion is " + "spelled config={'param': {'codec': ...}} or, for a whole convention, decode=." + ), +} + +#: Set it to silence :class:`BoundKeywordWarning` for a console script that has nowhere to +#: put a ``warnings.filterwarnings`` line. Named to rhyme with ``CW_COMPAT_QUIET``. +QUIET_ENV = "CW_QUIET" + @dataclasses.dataclass(frozen=True) class _Stash: @@ -83,6 +120,9 @@ class _Stash: func: Optional[Callable] = None ingress: Optional[Callable] = None config: Optional[Mapping] = None + #: ``{argparse dest: python parameter name}``, for the hyphenated positionals argh + #: registers under a name no Python call can use. Empty for every other argument. + renames: Mapping[str, str] = dataclasses.field(default_factory=dict) # -------------------------------------------------------------------------------------- @@ -98,6 +138,11 @@ def _new_parser(convention: Convention, parser_kwargs: dict) -> argparse.Argumen ``TypeError`` that never mentions cw. """ parser_kwargs.setdefault("formatter_class", ArghHelpFormatter) + misplaced = sorted(set(parser_kwargs) & set(SEAMS_ELSEWHERE)) + if misplaced: + raise TypeError( + "; ".join(f"{name}: {SEAMS_ELSEWHERE[name]}" for name in misplaced) + ) try: return argparse.ArgumentParser(**parser_kwargs) except TypeError as exc: @@ -113,7 +158,42 @@ def _new_parser(convention: Convention, parser_kwargs: dict) -> argparse.Argumen ) from exc -def _warn_bound_keywords(func: Any, specs) -> None: +def _child_formatter(formatter_class) -> type: + """The formatter every subparser gets, given the one its parent carries. + + argh hands ``PARSER_FORMATTER`` to every subparser it creates -- *unconditionally*, + even under a plain ``argparse.ArgumentParser`` whose own formatter it leaves alone + (verified against argh 0.31.3). So ``argparse.ArgumentParser() + add_commands`` gives + argh-looking subcommands and a stock root, and cw must do the same or the one-line + migration changes ``--help`` for every repo that holds a parser object: a ``None`` + default printing ``None`` instead of ``-``, a string default losing its quotes, and a + multi-paragraph docstring reflowed into one. + + cw promotes only the *stock* formatter rather than overriding unconditionally, so an + explicit ``formatter_class=`` still means what it says. The root parser is never + touched here -- :func:`mk_parser` defaults it at construction, ``ArghParser.__init__`` + defaults it for the 27 fleet call sites that use it, and a parser somebody else built + keeps whatever they gave it, exactly as under argh. + """ + return ( + ArghHelpFormatter + if formatter_class is argparse.HelpFormatter + else formatter_class + ) + + +def _func_label(func: Any) -> str: + """A stable, address-free name for ``func``, partials included. + + ``repr(functools.partial(f))`` embeds ``f``'s hex ``id()``, which makes any message + built from it differ on every run -- unusable in a golden, and noise in a warning. + """ + if isinstance(func, functools.partial): + return f"functools.partial({_func_label(func.func)})" + return getattr(func, "__name__", None) or type(func).__name__ + + +def _warn_bound_keywords(func: Any, specs, *, command: Optional[str] = None) -> None: """Warn about a ``functools.partial``'s pre-bound keyword that is still a CLI flag. :func:`inspect.signature` keeps a partial's bound keyword (Python moves it to @@ -123,18 +203,34 @@ def _warn_bound_keywords(func: Any, specs) -> None: A warning names the leak and the one line that closes it. It reads the finished ``specs`` rather than the signature, so that hiding the keyword - silences the warning -- a warning you cannot act on is noise. + silences the warning -- a warning you cannot act on is noise. Three further properties + matter, because this fires on *every* invocation of a CLI that has such a command, + ``--help`` included: + + * it names the **command** whose config key would close it, not a ``''`` + placeholder, so the suggested line can be pasted; + * it carries no ``id()``, so the text is the same on every run; and + * it is a :class:`BoundKeywordWarning`, so a repo that has decided the flag is fine can + silence exactly this one -- ``warnings.filterwarnings('ignore', + category=cw.BoundKeywordWarning)``, or :data:`QUIET_ENV` for a console script that + has nowhere to put that line -- **without** changing its CLI. Hiding the parameter + also silences it, but that removes a flag argh exposed, which is not the same thing. """ - if not isinstance(func, functools.partial): + if not isinstance(func, functools.partial) or os.environ.get(QUIET_ENV): return bound = set(func.keywords or ()) for spec in specs: if spec.param_name in bound: + key = ( + f"{{{command!r}: {{{spec.param_name!r}: cw.HIDE}}}}" + if command is not None + else f"{{{spec.param_name!r}: cw.HIDE}}" + ) warnings.warn( - f"{func!r}: the pre-bound keyword {spec.param_name!r} is still exposed " - f"as a command-line option. Hide it with " - f"config={{'': {{{spec.param_name!r}: cw.HIDE}}}}", - UserWarning, + f"{_func_label(func)}: the pre-bound keyword {spec.param_name!r} is " + f"still exposed as a command-line option. Hide it with config={key}, or " + f"silence this with {QUIET_ENV}=1 to keep the flag.", + BoundKeywordWarning, stacklevel=4, ) @@ -146,6 +242,7 @@ def set_default_command( *, config: Optional[Mapping] = None, convention: Convention = ARGH, + command: Optional[str] = None, ) -> argparse.ArgumentParser: """Bind one function to ``parser``: add its arguments, and stash how to call it. @@ -167,29 +264,40 @@ def set_default_command( The function's docstring becomes the parser's description, unless the parser already has one -- argh's rule, and it is what lets ``description=`` override it. + + ``command`` is the command word this function is bound to, when there is one. It is used + only in messages -- it is what lets the ``functools.partial`` warning print the + ``config`` key you would actually paste rather than a ``''`` placeholder -- so + a caller binding a single command has no reason to pass it. """ specs = specs_for_function( func, convention=convention, config=config, parser_adds_help=parser.add_help ) - _warn_bound_keywords(func, specs) + _warn_bound_keywords(func, specs, command=command) for spec in specs: if spec.param_name == RESERVED_DEST: raise GrammarError( - f"{getattr(func, '__name__', func)}: the parameter {RESERVED_DEST!r} " + f"{_func_label(func)}: the parameter {RESERVED_DEST!r} " "collides with the namespace key cw reserves for its own use. Rename the " "parameter, or hide it with cw.HIDE." ) args, kwargs = spec.add_argument_args() try: - parser.add_argument(*args, **kwargs) - except argparse.ArgumentError as exc: - raise GrammarError( - f"{getattr(func, '__name__', func)}: cannot add {spec.param_name!r} as " - f"{'/'.join(spec.flags)}: {exc}" - ) from exc + action = parser.add_argument(*args, **kwargs) + except Exception as exc: + raise GrammarError(_cannot_add(func, spec, exc)) from exc + if spec.completer is not None: + # argcomplete reads it off the action, which is why it cannot be an + # add_argument keyword. argh assigns it in the same place. + action.completer = spec.completer if not parser.description: parser.description = inspect.getdoc(func) codecs = {spec.param_name: spec.codec for spec in specs if spec.codec is not None} + renames = { + spec.argparse_dest: spec.param_name + for spec in specs + if spec.argparse_dest != spec.param_name + } _stash( parser, _Stash( @@ -197,11 +305,35 @@ def set_default_command( func=func, ingress=mk_ingress(func, codecs=codecs), config=config, + renames=renames, ), ) return parser +def _cannot_add(func: Any, spec, exc: Exception) -> str: + """The message for an ``add_argument`` call argparse refused. + + argparse says ``no`` in three different exception types and none of the messages names + the function, the parameter or the flags -- ``ValueError: dest= is required for options + like '---pool'`` is the whole of what a user sees otherwise. argh wraps all of them + (``AssemblingError: {func}: cannot add {param} as {flags}: {reason}``) and cw must not + be *worse* than the library it replaces at the one moment a migration goes wrong. + + The leading-underscore case gets an extra sentence, because it is a reproduced argh + footgun with a documented one-line fix that the raw message cannot mention. + """ + flags = "/".join(spec.flags) + message = f"{_func_label(func)}: cannot add {spec.param_name!r} as {flags}: {exc}" + if any(flag.startswith("---") or flag == "--" for flag in spec.flags): + message += ( + f". A parameter named {spec.param_name!r} starts with an underscore, which " + "argh -- and therefore cw -- hyphenates into an unusable flag. Hide it with " + f"config={{{spec.param_name!r}: cw.HIDE}} (it keeps its default), or rename it." + ) + return message + + def _stash(parser: argparse.ArgumentParser, stash: _Stash) -> None: parser.set_defaults(**{RESERVED_DEST: stash}) @@ -231,7 +363,9 @@ def _add_command( command_parser = subparsers.add_parser( name, help=func.__doc__, formatter_class=formatter_class ) - set_default_command(command_parser, func, config=config, convention=convention) + set_default_command( + command_parser, func, config=config, convention=convention, command=name + ) def _add_group( @@ -380,7 +514,7 @@ def mk_parser( commands_from(obj, convention=convention), config=config or {}, convention=convention, - formatter_class=parser.formatter_class, + formatter_class=_child_formatter(parser.formatter_class), ) return parser @@ -429,7 +563,7 @@ def add_commands( tree, config=config, convention=convention, - formatter_class=parser.formatter_class, + formatter_class=_child_formatter(parser.formatter_class), ) return parser _check_config_keys(tree, config, what="command") @@ -439,7 +573,7 @@ def add_commands( tree, config=config, convention=convention, - formatter_class=parser.formatter_class, + formatter_class=_child_formatter(parser.formatter_class), group_kwargs=group_kwargs, ) return parser @@ -628,8 +762,11 @@ def run( def _call_args(stash: _Stash, namespace, *, config, convention) -> tuple: """``(args, kwargs)`` for the stashed command, from the parsed namespace.""" + renames = stash.renames or {} values = { - name: value for name, value in vars(namespace).items() if name != RESERVED_DEST + renames.get(name, name): value + for name, value in vars(namespace).items() + if name != RESERVED_DEST } ingress = stash.ingress if config is not None: diff --git a/cw/commands.py b/cw/commands.py index bdf9c5b..7c0e039 100644 --- a/cw/commands.py +++ b/cw/commands.py @@ -133,6 +133,34 @@ def _public_callables(obj: Any) -> Dict[str, Callable]: return found +def _label(value: Any) -> str: + """``module.qualname`` for a callable, so a collision message names both sides.""" + module = getattr(value, "__module__", None) + qualname = getattr(value, "__qualname__", None) or getattr(value, "__name__", None) + if qualname is None: + return repr(value) + return f"{module}.{qualname}" if module else qualname + + +def _put(tree: CommandTree, name: str, value: Any) -> None: + """Add one command to ``tree``, refusing to overwrite a name already taken. + + argh raises ``ArgumentError: conflicting subparser`` for this; cw used to keep the last + one, so ``dispatch([run_a, run_b])`` -- two functions imported from different modules + that happen to share a ``__name__``, or an ``__all__`` listing a name twice -- silently + lost a command and ran the wrong one with exit 0. A crash is strictly better than a + wrong answer, and this is the one place a derived name is written down. + """ + if name in tree: + raise CommandTreeError( + f"two commands are both called {name!r}: {_label(tree[name])} and " + f"{_label(value)}. argh refuses this too (`conflicting subparser`). Name them " + "apart with the mapping form -- cw.dispatch({'run-a': first, 'run-b': " + "second}) -- or put them in different groups." + ) + tree[name] = value + + def _named(name: str, *, convention, group: bool = False) -> str: """One naming function for derived names, mapping keys and group names alike. @@ -207,10 +235,10 @@ def commands_from(obj: Any, /, *, convention=None, _depth: int = 0) -> CommandTr if isinstance(obj, Iterable): return _from_iterable(obj, convention=convention) if inspect.ismodule(obj) or hasattr(obj, "__dict__"): - return { - _named(name, convention=convention): func - for name, func in _public_callables(obj).items() - } + tree: CommandTree = {} + for name, func in _public_callables(obj).items(): + _put(tree, _named(name, convention=convention), func) + return tree raise CommandTreeError( f"cannot derive commands from {obj!r}: it is not a callable, a mapping, an " "iterable of callables, a module, or a 'pkg.mod:name' reference." @@ -224,7 +252,7 @@ def _from_mapping(obj: Mapping, *, convention, _depth: int) -> CommandTree: if isinstance(value, str): value = import_object(value) if is_command(value): - tree[_named(key, convention=convention)] = value + _put(tree, _named(key, convention=convention), value) elif _depth >= MAX_GROUP_DEPTH: raise CommandTreeError( f"group {key!r} is nested more than {MAX_GROUP_DEPTH} level deep. argh " @@ -232,8 +260,10 @@ def _from_mapping(obj: Mapping, *, convention, _depth: int) -> CommandTree: "or give its commands longer names." ) else: - tree[_named(key, convention=convention, group=True)] = commands_from( - value, convention=convention, _depth=_depth + 1 + _put( + tree, + _named(key, convention=convention, group=True), + commands_from(value, convention=convention, _depth=_depth + 1), ) return tree @@ -249,7 +279,7 @@ def _from_iterable(obj: Iterable, *, convention) -> CommandTree: "__all__ is used), or spell the mapping: " "{name: getattr(module, name) for name in module.__all__}." ) - tree[command_name(value, hyphenate=convention.hyphenate_commands)] = value + _put(tree, command_name(value, hyphenate=convention.hyphenate_commands), value) return tree diff --git a/cw/compat.py b/cw/compat.py index 3d0aa1c..086fc70 100644 --- a/cw/compat.py +++ b/cw/compat.py @@ -75,7 +75,7 @@ class it has never heard of, so ``CommandError: boom`` / exit 1 becomes an unhan from typing import Any, Callable, Mapping, Optional import cw -from cw.base import MISSING +from cw.base import MISSING, ArghHelpFormatter from cw.cli import add_commands as _cw_add_commands from cw.cli import dispatch as _cw_dispatch from cw.cli import mk_parser as _cw_mk_parser @@ -383,10 +383,29 @@ def arg(*flags: str, **add_argument_kwargs) -> Callable: ... '''Start a project.''' >>> quickstart._cw['params']['ignore'] {'nargs': '*', 'flags': ['-i', '--ignore']} + + Two argh keywords are *not* ``add_argument`` keywords and are handled the way argh + handles them. ``dest=`` says which parameter a differently-spelt flag refers to + (``argh/decorators.py:143-146``), so it decides the key rather than being passed on: + + >>> @argh.arg('--al', dest='alpha', help='aliased') + ... def scale(alpha=1): ... + >>> sorted(scale._cw['params']) + ['alpha'] + + And ``completer=`` is argcomplete's per-argument hook, which ``add_argument`` rejects; + it travels in the leaf and :mod:`cw.cli` assigns it to the action it creates. + + >>> @argh.arg('--host', completer=lambda **kw: ['localhost']) + ... def serve(host='0.0.0.0'): ... + >>> callable(serve._cw['params']['host']['completer']) + True """ def decorate(func): - param = _param_name_of(flags) + # argh pops `dest` and uses it as the parameter name; without this, `@arg('--al', + # dest='alpha')` looks like a declaration for a parameter called `al`. + param = add_argument_kwargs.pop("dest", None) or _param_name_of(flags) # Innermost decorator runs first but must read last: argh's own `insert(0, ...)`. declared = { param: dict(add_argument_kwargs, flags=list(flags)), @@ -427,8 +446,21 @@ class ArghParser(argparse.ArgumentParser): >>> parser.add_commands([ping]) >>> parser.dispatch(['ping'], output_file=None) 'pong\\n' + + It defaults ``formatter_class`` exactly as argh's own ``ArghParser.__init__`` does. + Without that line the one-line migration changes ``--help`` for every repo that holds + a parser object: a ``None`` default renders ``None`` instead of argh's ``-``, a string + default loses its quotes, and a multi-paragraph docstring is reflowed into one. + + >>> from cw import ArghHelpFormatter + >>> ArghParser(prog='demo').formatter_class is ArghHelpFormatter + True """ + def __init__(self, *args, **kwargs): + kwargs.setdefault("formatter_class", ArghHelpFormatter) + super().__init__(*args, **kwargs) + def add_commands(self, *args, **kwargs) -> None: """:func:`cw.compat.add_commands`, on this parser.""" return add_commands(self, *args, **kwargs) @@ -537,6 +569,26 @@ def _reject(parser_kwargs: Mapping, where: str) -> None: "wrap_errors": "Zero fleet uses. Raise cw.CommandError from the command instead.", "raw_output": "Not a name -- it is a `dispatch` keyword, and it is accepted as one.", "ArghNamespace": "An argh internal. cw's parsers produce a plain argparse.Namespace.", + "assembling": ( + "cw.compat is a single module, not a package, so there is no `cw.compat." + "assembling`. The one name four fleet files import from it is exported here " + "directly: `from cw.compat import NameMappingPolicy`." + ), + "interaction": ( + "cw.compat is a single module, not a package. `argh.interaction.confirm` is " + "`cw.compat.confirm` (and `cw.confirm`)." + ), + "PARSER_FORMATTER": ( + "argh's `PARSER_FORMATTER` is `cw.ArghHelpFormatter`, which cw.compat.ArghParser " + "and cw.mk_parser already default to. Pass it as " + "`formatter_class=cw.ArghHelpFormatter` if you are building the parser yourself." + ), + "expects_obj": ( + "argh's `expects_obj` hands the raw argparse Namespace to the function instead of " + "calling it with its own parameters. Zero fleet uses, and it is the opposite of " + "what cw is for: take the Namespace yourself with " + "`cw.mk_parser(...).parse_args(argv)`." + ), } diff --git a/cw/grammar.py b/cw/grammar.py index fc7e47f..7dcb2ab 100644 --- a/cw/grammar.py +++ b/cw/grammar.py @@ -146,6 +146,10 @@ class ArgSpec: codec: Optional[Codec] = None #: Set by a ``cw.HIDE`` override: keep the parameter, drop the CLI argument. hidden: bool = False + #: argcomplete's per-argument completer. Not an ``add_argument`` keyword -- argparse + #: rejects it -- so it travels here and ``cw.cli`` assigns it to the created action, + #: which is where ``argcomplete`` looks for it. + completer: Any = None @property def is_positional(self) -> bool: @@ -186,6 +190,8 @@ def update(self, other: "ArgSpec") -> None: self.nargs = other.nargs if other.codec is not None: self.codec = other.codec + if other.completer is not None: + self.completer = other.completer self.extra.update(other.extra) def add_argument_kwargs(self) -> Dict[str, Any]: @@ -207,23 +213,50 @@ def add_argument_kwargs(self) -> Dict[str, Any]: def add_argument_args(self) -> tuple: """The full ``(args, kwargs)`` of the ``add_argument`` call this spec describes. - One divergence from argh lives here and only here. argh registers a hyphenated - positional as ``add_argument('project-dir')``, producing a ``dest`` that literally - contains a hyphen, and then repairs it downstream. cw registers - ``add_argument('project_dir', metavar='project-dir')``, which renders identically - in ``usage:``, in ``--help`` and in argparse's error messages, and needs no repair. - - >>> ArgSpec('project_dir', ['project-dir']).add_argument_args() - (('project_dir',), {'metavar': 'project-dir'}) + A positional is registered under its **command-line** name, hyphens and all -- + exactly as argh does -- because argparse reads that one string twice, and the two + readings cannot be separated: it is the ``{a,b}``-or-``project-dir`` displayed in + ``usage:`` *and* the name in ``error: argument project-dir: ...``. Synthesising a + ``metavar`` instead would win the second reading and lose the first, silently + turning ``{a,b}`` into ``project-dir`` for any hyphenated positional carrying + ``choices``. + + The price is a ``dest`` with a hyphen in it, which no Python call can use. + :attr:`argparse_dest` names it and :mod:`cw.cli` renames it back on the way into + the call -- one dictionary lookup, in one place. + + >>> spec = ArgSpec('project_dir', ['project-dir']) + >>> spec.add_argument_args() + (('project-dir',), {}) + >>> spec.argparse_dest + 'project-dir' """ kwargs = self.add_argument_kwargs() if self.is_positional: - kwargs.setdefault("metavar", self.flags[0]) - if kwargs["metavar"] == self.param_name: - del kwargs["metavar"] - return (self.param_name,), kwargs + return (self.flags[0],), kwargs return tuple(self.flags), kwargs + @property + def argparse_dest(self) -> str: + """The namespace key argparse will store this argument under. + + argparse derives it from the first long option (``--project-dir`` -> + ``project_dir``) or, for a positional, from the name itself -- hyphens intact. + + >>> ArgSpec('project_dir', ['-p', '--project-dir']).argparse_dest + 'project_dir' + >>> ArgSpec('project_dir', ['project-dir']).argparse_dest + 'project-dir' + """ + if "dest" in self.extra: + return self.extra["dest"] + if self.is_positional: + return self.flags[0] + long = next( + (flag for flag in self.flags if flag.startswith("--")), self.flags[0] + ) + return long.lstrip("-").replace("-", "_") + @classmethod def from_override(cls, param_name: str, override: Mapping[str, Any]) -> "ArgSpec": """Build a spec from an override leaf -- ``add_argument`` kwargs plus cw's three. @@ -235,7 +268,7 @@ def from_override(cls, param_name: str, override: Mapping[str, Any]) -> "ArgSpec >>> ArgSpec.from_override('synth', {'flags': ['-s'], 'nargs': '?'}) ArgSpec(param_name='synth', flags=['-s'], required=cw.MISSING, default=cw.MISSING, - nargs='?', extra={}, codec=None, hidden=False) + nargs='?', extra={}, codec=None, hidden=False, completer=None) """ if not isinstance(override, Mapping): raise GrammarError( @@ -247,7 +280,10 @@ def from_override(cls, param_name: str, override: Mapping[str, Any]) -> "ArgSpec codec = rest.pop("codec", None) if codec is not None and not isinstance(codec, Codec): codec = Codec(decode=codec) - spec = cls(param_name=param_name, flags=flags, codec=codec) + # argcomplete's own keyword, which argparse's add_argument rejects. argh pops it + # the same way and assigns it to the action it just created. + completer = rest.pop("completer", None) + spec = cls(param_name=param_name, flags=flags, codec=codec, completer=completer) # argh's `make_from_kwargs` POPS these three out of the kwargs dict so that # `update` can merge them by their own rules. Same here. for key in FIELD_MERGED_KEYS: @@ -340,10 +376,24 @@ def modern_decode(param: inspect.Parameter, hint: Any) -> Mapping[str, Any]: >>> decoded = modern_decode(p, Colour) >>> decoded['type']('RED'), decoded['type']('r') (, ) + + The help column advertises what the converter accepts, rather than member ``repr``\\ s + the converter would reject: + + >>> decoded['metavar'] + '{RED}' """ hint = _unwrap_optional(hint) if isinstance(hint, type) and issubclass(hint, enum.Enum): - return {"type": _enum_by_name_then_value(hint), "choices": tuple(hint)} + # `choices` must hold the CONVERTED values (argparse checks after `type` runs), so + # it holds members -- but members render as `Col.RED`, which the converter would + # then reject. `metavar` decides what is displayed, so it advertises the member + # names, which are exactly what may be typed. + return { + "type": _enum_by_name_then_value(hint), + "choices": tuple(hint), + "metavar": "{" + ",".join(member.name for member in hint) + "}", + } if isinstance(hint, type) and issubclass(hint, pathlib.PurePath): return {"type": pathlib.Path} return argh_decode(param, hint) diff --git a/cw/testing.py b/cw/testing.py index 634eff7..9fc1d9a 100644 --- a/cw/testing.py +++ b/cw/testing.py @@ -32,11 +32,22 @@ 3 the full ``--help`` body snapshot only ===== =========================================================== ================= -Tier 3 is never asserted because ``--help`` wraps to ``COLUMNS`` and a big CLI's help is -hundreds of lines; asserting it would produce false failures forever. It is diffed -advisorily by :func:`diff_help`. Tier 2 does most of the work tier 3 looks like it would: -argparse's ``usage:`` line names **every** option a parser has, so a lost flag, a lost short -flag or a changed ``nargs`` all show up there, whitespace-collapsed and width-independent. +Tier 3's body is not asserted by default because ``--help`` wraps to ``COLUMNS`` and +because CPython itself rewrites it between versions (3.13 renders ``-i, --ignore VALUE`` +where 3.12 rendered ``-i VALUE, --ignore VALUE``), so a committed golden replayed across a +matrix would fail for reasons nobody caused. Tier 2 does most of the work tier 3 looks like +it would: argparse's ``usage:`` line names **every** option a parser has, so a lost flag, a +lost short flag or a changed ``nargs`` all show up there, whitespace-collapsed and +width-independent. + +**What tier 2 cannot see, and what to do about it.** A change of *formatter* -- which is +exactly what swapping one dispatcher for another can do -- moves the help column and the +description block and touches neither the ``usage:`` line nor any exit code. So +:func:`replay` compares the tier-3 body anyway, through :func:`normalise_help`, and reports +a case whose body moved as the non-fatal status ``help-differs`` rather than calling it +``identical``. ``replay(..., strict_help=True)`` (``--strict-help`` on the command line) +makes it fatal, which is the right setting for a migration that promised ``--help`` would +not move; :func:`diff_help` prints the unnormalised difference for a human to read. Windows (ADR: option A, "normalise") ------------------------------------ @@ -118,6 +129,10 @@ #: Seconds a single recorded case may take before it is reported as a failure. DFLT_TIMEOUT = 30 +#: A blank line -- the one whitespace that means something in a ``--help`` body, because a +#: paragraph break survives wrapping and a line break inside a paragraph does not. +_BLANK_LINE = re.compile(r"\n[ \t]*\n") + #: Where :func:`parity` looks when it is not told. Ships inside the package, so the gate #: runs from an installed ``cw`` and not only from a checkout. DFLT_GOLDENS_DIR = os.path.join( @@ -185,6 +200,29 @@ def canonical_argparse_text(text: str) -> str: return _USAGE.sub(lambda m: " ".join(m.group(0).split()), text) +def normalise_help(text: str) -> str: + """A ``--help`` body with wrapping removed but paragraph structure kept. + + Everything the terminal width controls is whitespace *inside* a paragraph, so collapsing + each paragraph to single-spaced words makes the body width-independent while still + showing that ``argh``'s ``RawDescriptionHelpFormatter`` was lost (a multi-paragraph + docstring reflowed into one paragraph) and that a default rendered ``None`` where it + rendered ``-``. + + It is what ``--strict-help`` compares. It is deliberately **not** what :func:`parity` + compares: parity replays goldens across a CPython matrix, and 3.13 rewrote argparse's + own option column (``-i, --ignore [IGNORE ...]`` where 3.12 wrote + ``-i [IGNORE ...], --ignore [IGNORE ...]``), which is CPython's change and not cw's. + A ``characterize`` / ``replay`` pair runs on one machine at one interpreter, where that + cannot happen. + + >>> normalise_help('Serve it.\\n\\nSecond para,\\nwrapped.\\n') + 'Serve it.\\n\\nSecond para, wrapped.' + """ + blocks = _BLANK_LINE.split(normalise_text(text or "")) + return "\n\n".join(" ".join(block.split()) for block in blocks if block.strip()) + + def normalise_usage(text: str) -> str: """The ``usage:`` block of ``text``, whitespace-collapsed and width-independent. @@ -619,13 +657,19 @@ def load_golden(golden) -> dict: # --------------------------------------------------------------------------------------- -def compare_case(recorded: dict, fresh: dict) -> str: +def compare_case(recorded: dict, fresh: dict, *, strict_help: bool = False) -> str: """The empty string if ``fresh`` matches ``recorded``, else a readable diff. Which fields are compared is the tier rule and nothing else: tier 1 asserts the return code and both streams in full, tier 3 asserts the return code and the normalised ``usage:`` line and leaves the ``--help`` body to :func:`diff_help`. + ``strict_help=True`` adds the ``--help`` body to a tier-3 case, compared through + :func:`normalise_help` so that the terminal width cannot decide the verdict. Use it when + the *rendering* is part of what the migration promised not to change -- swapping argh's + formatter for argparse's stock one is invisible to the tier-3 fields, because it moves + only the help column and the description block. + >>> a = {'tier': 1, 'returncode': 0, 'stdout': 'hi\\n', 'stderr': '', 'usage': ''} >>> compare_case(a, dict(a)) '' @@ -644,7 +688,8 @@ def compare_case(recorded: dict, fresh: dict) -> str: ... {'tier': 3, 'returncode': 2, 'usage': bare}) '' """ - fields = TIER1_FIELDS if recorded.get("tier", 1) == 1 else TIER3_FIELDS + tier = recorded.get("tier", 1) + fields = TIER1_FIELDS if tier == 1 else TIER3_FIELDS chunks = [] for field in fields: want, got = recorded.get(field), fresh.get(field) @@ -656,9 +701,18 @@ def compare_case(recorded: dict, fresh: dict) -> str: got = canonical_argparse_text(normalise_text(got)) if want != got: chunks.append(f"{field}:\n" + _text_diff(want, got)) + if strict_help and tier != 1: + chunks.extend(help_body_diff(recorded, fresh)) return "\n".join(chunks) +def help_body_diff(recorded: dict, fresh: dict) -> list: + """``['help:\n']`` when the two ``--help`` bodies differ, else ``[]``.""" + want = canonical_argparse_text(normalise_help(recorded.get("stdout"))) + got = canonical_argparse_text(normalise_help(fresh.get("stdout"))) + return [] if want == got else ["help:\n" + _text_diff(want, got)] + + def _text_diff(want, got) -> str: """A unified-ish diff of two blobs, indented so it reads under a field name.""" lines = difflib.ndiff( @@ -685,6 +739,7 @@ def replay( cwd=None, expect_diff=(), timeout=DFLT_TIMEOUT, + strict_help=False, ) -> list: """Re-run a golden's cases and report, per case, whether the behaviour survived. @@ -699,11 +754,19 @@ def replay( entry that turns out identical is reported as ``unexpected-match``, because a migration note claiming a break that did not happen is also wrong. + strict_help: Also assert each tier-3 case's ``--help`` **body**, through + :func:`normalise_help`. Off by default, because a wider ``--help`` is usually + not what a migration promised; on, because a *formatter* change is not visible + in any of the fields tier 3 asserts. When it is off, a body that moved is still + reported -- as the non-fatal status ``help-differs`` -- so that a migration is + never told "identical" about output that visibly changed. + Returns: One dict per case: ``argv``, ``status`` and ``diff``. - Statuses are ``identical``, ``differs``, ``expected-diff`` and ``unexpected-match``. - :func:`assert_replay` is the version that raises. + Statuses are ``identical``, ``differs``, ``help-differs``, ``expected-diff`` and + ``unexpected-match``. :func:`assert_replay` is the version that raises, and it raises + on ``differs`` and ``unexpected-match`` only. """ golden = load_golden(golden) command = _as_command(prog if prog is not None else golden["prog"]) @@ -718,19 +781,28 @@ def replay( ), argv, ) - results.append(_verdict(recorded, fresh, intended)) + results.append(_verdict(recorded, fresh, intended, strict_help=strict_help)) return results -def _verdict(recorded: dict, fresh: dict, intended: set) -> dict: +def _verdict(recorded: dict, fresh: dict, intended: set, *, strict_help=False) -> dict: """One case's outcome, with ``expect_diff`` applied in both directions.""" argv = list(recorded["argv"]) - diff = compare_case(recorded, fresh) + diff = compare_case(recorded, fresh, strict_help=strict_help) if tuple(argv) in intended: - status = "expected-diff" if diff else "unexpected-match" - else: - status = "differs" if diff else "identical" - return {"argv": argv, "status": status, "diff": diff} + return { + "argv": argv, + "status": "expected-diff" if diff else "unexpected-match", + "diff": diff, + } + if diff: + return {"argv": argv, "status": "differs", "diff": diff} + # Nothing asserted moved. Say so, but do not say "identical" about a `--help` whose + # body changed: that is the exact sentence this tool exists to be trusted about. + advisory = [] if strict_help else help_body_diff(recorded, fresh) + if advisory: + return {"argv": argv, "status": "help-differs", "diff": "\n".join(advisory)} + return {"argv": argv, "status": "identical", "diff": ""} def assert_replay(golden, **kwargs) -> None: @@ -883,6 +955,11 @@ def _cli() -> argparse.ArgumentParser: again.add_argument("golden") again.add_argument("--prog", default=None) again.add_argument("--expect-diff", action="append", default=[]) + again.add_argument( + "--strict-help", + action="store_true", + help="fail on a changed --help body too, not just report it", + ) advisory = subs.add_parser("diff-help", help="the advisory tier-3 --help diff") advisory.add_argument("golden") @@ -908,11 +985,23 @@ def main(argv=None) -> int: if args.command == "diff-help": print(diff_help(args.golden, prog=args.prog) or "no --help changes") return 0 - results = replay(args.golden, prog=args.prog, expect_diff=args.expect_diff) + results = replay( + args.golden, + prog=args.prog, + expect_diff=args.expect_diff, + strict_help=args.strict_help, + ) bad = [r for r in results if r["status"] in ("differs", "unexpected-match")] - for result in bad: + advisory = [r for r in results if r["status"] == "help-differs"] + for result in bad + advisory: print(_report_line(result)) - print(f"{len(results) - len(bad)}/{len(results)} identical") + line = f"{len(results) - len(bad) - len(advisory)}/{len(results)} identical" + if advisory: + line += ( + f", {len(advisory)} with a changed --help body (advisory; " + "re-run with --strict-help to fail on it, or `diff-help` to read it)" + ) + print(line) return 1 if bad else 0 diff --git a/cw/tests/README.md b/cw/tests/README.md index 952b5c2..6101df2 100644 --- a/cw/tests/README.md +++ b/cw/tests/README.md @@ -4,7 +4,7 @@ python -m cw.testing parity ``` -> **v1 is done when that prints `8 shapes / 133 cases: identical` and exits 0.** +> **v1 is done when that prints `8 shapes / 137 cases: identical` and exits 0.** `fixtures.py` holds eight self-contained shapes; `goldens/*.json` holds what **real argh 0.31.3** did with each of their argv vectors, recorded once and committed. `parity` replays @@ -20,14 +20,14 @@ the normalised `usage:` line.** | `coact` | `t/coact` | 19 | | `xa` | `t/xa` | 17 | | `wads_pack` | `i/wads` | 17 | -| `lacing` | `t/lacing` | 9 | +| `lacing` | `t/lacing` | 13 | | `contract` | the D2 contract's egress and error rows | 17 | -| **total** | | **133** | +| **total** | | **137** | That total is **counted**, not asserted: `tests/test_corpus_coverage.py::test_the_case_count_is_counted_rather_than_asserted` recomputes it from `fixtures.SHAPES`. The canonical spec's "7 repos / 214 cases" was an -invented number with no file behind it; this one has 133 argv vectors in a file, each with a +invented number with no file behind it; this one has 137 argv vectors in a file, each with a comment saying which argh rule it pins. ## Why shapes and not the seven repos diff --git a/cw/tests/fixtures.py b/cw/tests/fixtures.py index a60bd59..f6f5e47 100644 --- a/cw/tests/fixtures.py +++ b/cw/tests/fixtures.py @@ -46,6 +46,7 @@ """ import functools +from typing import Literal __all__ = [ "SHAPES", @@ -893,6 +894,20 @@ def list_formats(): return ["annot", "csv", "json"] +# `convert_tree` below is a hyphenated positional that ALSO carries `choices` -- a shape +# both corpora were structurally blind to until an adversarial review found it. argparse +# reads a positional's registered name twice: as the displayed name in `usage:` and +# `--help`, and as the name in `error: argument ...`. A synthesised `metavar` wins only the +# second reading, so cw's old `add_argument('source_dir', metavar='source-dir')` printed +# `source-dir` where argh prints `{annots,csvs}` -- with an identical error message, which +# is why no error-level case could see it. Every `choices` case in both corpora was on an +# OPTION, and every `Literal` positional had a one-word name. Keep the docstring short: +# argh renders the RAW docstring into the parent's command listing. +def convert_tree(source_dir: Literal["annots", "csvs"], *, dry_run=False): + """Convert a whole tree of annotation files.""" + return f"convert_tree({source_dir!r}, dry_run={dry_run!r})" + + LACING = Shape( "lacing", prog="lacing", @@ -901,15 +916,16 @@ def list_formats(): "A quoted annotation -- `to_version: 'int | None'` -- is read raw and matches " "nothing, so the value arrives as a str and `repr` shows the quotes. This is live " "in ~32 fleet files. Under cw.MODERN it would resolve to int; under cw.ARGH, which " - "is the default and what parity asserts, it must NOT." + "is the default and what parity asserts, it must NOT. `convert-tree` adds the " + "hyphenated-positional-with-choices shape that both corpora were blind to." ), rows=(1, 13, 16), - cw_obj=[migrate, convert, list_formats], + cw_obj=[migrate, convert, list_formats, convert_tree], cw_kwargs={"description": "Annotation store tooling."}, argh_build=lambda argh, argparse: _subcommands( argh, argparse, - [migrate, convert, list_formats], + [migrate, convert, list_formats, convert_tree], prog="lacing", description="Annotation store tooling.", ), @@ -925,6 +941,13 @@ def list_formats(): # A list return iterates, one line each -- row 16, the egress whitelist. ["list-formats"], ["migrate"], + # The hyphenated-positional-with-choices shape. The `--help` case is the one that + # bites: a metavar-based dest repair prints `source-dir` where argh prints + # `{annots,csvs}`, and the two error cases below stay identical either way. + ["convert-tree", "--help"], + ["convert-tree", "annots"], + ["convert-tree", "zzz"], + ["convert-tree"], ], ) diff --git a/cw/tests/goldens/lacing.json b/cw/tests/goldens/lacing.json index 70cdb75..6968159 100644 --- a/cw/tests/goldens/lacing.json +++ b/cw/tests/goldens/lacing.json @@ -4,9 +4,9 @@ "argv": [], "returncode": 0, "stderr": "", - "stdout": "usage: lacing [-h] {migrate,convert,list-formats} ...\n", + "stdout": "usage: lacing [-h] {migrate,convert,list-formats,convert-tree} ...\n", "tier": 1, - "usage": "usage: lacing [-h] {migrate,convert,list-formats} ..." + "usage": "usage: lacing [-h] {migrate,convert,list-formats,convert-tree} ..." }, { "argv": [ @@ -14,9 +14,9 @@ ], "returncode": 0, "stderr": "", - "stdout": "usage: lacing [-h] {migrate,convert,list-formats} ...\n\nAnnotation store tooling.\n\npositional arguments:\n {migrate,convert,list-formats}\n migrate Upgrade PATH to the current store schema, in place. Row 13: argh reads\n `__annotations__` raw, so this annotation is the STRING `'int | None'`,\n which is not in its if-chain. The guesser returns nothing, the default is\n None so the default-value path yields nothing either, and `to_version`\n arrives as a `str`. That is why lacing's own body says \"argh delivers\n option values as strings; coerce here\" -- and why the coercion may not be\n deleted until MODERN is opted into.\n convert Convert an annotation file between formats.\n list-formats List the annotation formats lacing understands.\n\noptions:\n -h, --help show this help message and exit\n", + "stdout": "usage: lacing [-h] {migrate,convert,list-formats,convert-tree} ...\n\nAnnotation store tooling.\n\npositional arguments:\n {migrate,convert,list-formats,convert-tree}\n migrate Upgrade PATH to the current store schema, in place. Row 13: argh reads\n `__annotations__` raw, so this annotation is the STRING `'int | None'`,\n which is not in its if-chain. The guesser returns nothing, the default is\n None so the default-value path yields nothing either, and `to_version`\n arrives as a `str`. That is why lacing's own body says \"argh delivers\n option values as strings; coerce here\" -- and why the coercion may not be\n deleted until MODERN is opted into.\n convert Convert an annotation file between formats.\n list-formats List the annotation formats lacing understands.\n convert-tree Convert a whole tree of annotation files.\n\noptions:\n -h, --help show this help message and exit\n", "tier": 3, - "usage": "usage: lacing [-h] {migrate,convert,list-formats} ..." + "usage": "usage: lacing [-h] {migrate,convert,list-formats,convert-tree} ..." }, { "argv": [ @@ -100,6 +100,49 @@ "stdout": "", "tier": 1, "usage": "usage: lacing migrate [-h] [-t TO_VERSION] path lacing migrate: error: the following arguments are required: path" + }, + { + "argv": [ + "convert-tree", + "--help" + ], + "returncode": 0, + "stderr": "", + "stdout": "usage: lacing convert-tree [-h] [-d] {annots,csvs}\n\nConvert a whole tree of annotation files.\n\npositional arguments:\n {annots,csvs} -\n\noptions:\n -h, --help show this help message and exit\n -d, --dry-run False\n", + "tier": 3, + "usage": "usage: lacing convert-tree [-h] [-d] {annots,csvs}" + }, + { + "argv": [ + "convert-tree", + "annots" + ], + "returncode": 0, + "stderr": "", + "stdout": "convert_tree('annots', dry_run=False)\n", + "tier": 1, + "usage": "" + }, + { + "argv": [ + "convert-tree", + "zzz" + ], + "returncode": 2, + "stderr": "usage: lacing convert-tree [-h] [-d] {annots,csvs}\nlacing convert-tree: error: argument source-dir: invalid choice: 'zzz' (choose from annots, csvs)\n", + "stdout": "", + "tier": 1, + "usage": "usage: lacing convert-tree [-h] [-d] {annots,csvs} lacing convert-tree: error: argument source-dir: invalid choice: 'zzz' (choose from annots, csvs)" + }, + { + "argv": [ + "convert-tree" + ], + "returncode": 2, + "stderr": "usage: lacing convert-tree [-h] [-d] {annots,csvs}\nlacing convert-tree: error: the following arguments are required: source-dir\n", + "stdout": "", + "tier": 1, + "usage": "usage: lacing convert-tree [-h] [-d] {annots,csvs} lacing convert-tree: error: the following arguments are required: source-dir" } ], "cw_golden": 1, @@ -114,7 +157,7 @@ "models": "t/lacing", "newlines": "lf", "note": "Recorded in-process from real argh against cw/tests/fixtures.py. argh is not a dependency of cw; see misc/record_goldens.py.", - "pins": "A quoted annotation -- `to_version: 'int | None'` -- is read raw and matches nothing, so the value arrives as a str and `repr` shows the quotes. This is live in ~32 fleet files. Under cw.MODERN it would resolve to int; under cw.ARGH, which is the default and what parity asserts, it must NOT.", + "pins": "A quoted annotation -- `to_version: 'int | None'` -- is read raw and matches nothing, so the value arrives as a str and `repr` shows the quotes. This is live in ~32 fleet files. Under cw.MODERN it would resolve to int; under cw.ARGH, which is the default and what parity asserts, it must NOT. `convert-tree` adds the hyphenated-positional-with-choices shape that both corpora were blind to.", "prog": [ "lacing" ], diff --git a/docs/adr/0001-the-v1-seam-table.md b/docs/adr/0001-the-v1-seam-table.md index 1081bb5..bcdf0eb 100644 --- a/docs/adr/0001-the-v1-seam-table.md +++ b/docs/adr/0001-the-v1-seam-table.md @@ -20,9 +20,24 @@ substance of this ADR, not a footnote to it. ## Decision -**Three seams. Each is exactly one keyword argument on `cw.dispatch` / `cw.mk_parser`. -Each default is a real, complete implementation — never a stub. Each has a replacement -that exists on disk today.** +**Three seams. Each is exactly one keyword argument — never a registry, a base class or a +plugin loader. Each default is a real, complete implementation, never a stub. Each has a +replacement that exists on disk today.** + +Which entry point carries which keyword follows from *when* the seam runs, and `cw.dispatch` +— being `run ∘ mk_parser` — carries all three: + +| seam | `mk_parser` | `add_commands` | `run` | `dispatch` | +|---|---|---|---|---| +| `decode=` — shapes the parser | ✅ | ✅ | — | ✅ | +| `egress=` — runs after the call | — | — | ✅ | ✅ | +| `convention=` — carries both, per context | ✅ | ✅ | ✅ | ✅ | + +A seam named on a call that cannot honour it says so and names the call that can +(`cw.cli.SEAMS_ELSEWHERE`), rather than reporting that `argparse.ArgumentParser` has no +such keyword. To bind an egress to a *parser*, put it on the convention: +`convention=dataclasses.replace(cw.ARGH, egress=my_egress)` — which is the same one keyword +argument, and the reason `decode` and `egress` are convention fields at all. | # | Seam (one kwarg) | v1 default — real, not a stub | Replacement you can point at | |---|---|---|---| @@ -138,7 +153,7 @@ three. binding; a fourth keyword argument that switches behaviour is a change to this table, written as a new ADR that supersedes it. - Every default named here is a working implementation, asserted by - `python -m cw.testing parity` (8 shapes / 133 cases against goldens recorded from live + `python -m cw.testing parity` (8 shapes / 137 cases against goldens recorded from live argh 0.31.3) and by `tests/argh_parity/` (a live differential when `cw[dev]` is installed). A seam whose default became a stub would fail the gate, not merely read badly. diff --git a/docs/adr/0005-release-and-rollback-policy.md b/docs/adr/0005-release-and-rollback-policy.md index 66f4256..98956f3 100644 --- a/docs/adr/0005-release-and-rollback-policy.md +++ b/docs/adr/0005-release-and-rollback-policy.md @@ -60,7 +60,7 @@ This is the load-bearing rule of the whole policy. `ARGH` means "argh 0.31.3's g ### 3. The grammar-freeze test — cw's CI is the release gate -`python -m cw.testing parity` replays 8 shapes / 133 cases against goldens recorded from +`python -m cw.testing parity` replays 8 shapes / 137 cases against goldens recorded from live argh 0.31.3 and committed to the repo. It runs in cw's own CI on every push, on 3.10 and 3.12, on Linux, macOS and Windows. **An `ARGH` drift therefore fails cw's CI before the publish step runs**, because wads' publish job is gated on the test job. @@ -130,7 +130,7 @@ stderr: + GrammarError: theremin_cli: cannot add 'log_knobs' as -l/--log-knobs: argument -l/--log-knobs: conflicting option string: -l ... -8 shapes / 133 cases: 36 DIFFER +8 shapes / 137 cases: 36 DIFFER exit=1 ``` @@ -153,7 +153,7 @@ options: --pool POOL - $ python -m cw.testing parity -8 shapes / 133 cases: identical +8 shapes / 137 cases: identical exit=0 ``` diff --git a/docs/adr/0007-what-the-adversarial-review-changed.md b/docs/adr/0007-what-the-adversarial-review-changed.md new file mode 100644 index 0000000..b2bd457 --- /dev/null +++ b/docs/adr/0007-what-the-adversarial-review-changed.md @@ -0,0 +1,145 @@ +# 0007 — What the adversarial review changed + +**Status:** accepted (2026-08-30). Supersedes canonical-spec §9.3 and amends +[0001](0001-the-v1-seam-table.md) and [0005](0005-release-and-rollback-policy.md). + +## Context + +Three independent reviewers went over `build-v1` at `f94ed12` with instructions to refute +it. Between them they wrote a fresh differential of ~123 cases, migrated all seven hard-case +fleet repos with running code, diffed the live installed `priv` CLI, and drove argcomplete's +`COMP_LINE` protocol end to end. The grammar survived: 88 pure signature/annotation/collision +cases and 20 end-to-end invocations came back byte-identical to argh 0.31.3, PEP 563 is +handled, the seams are real, and `import cw` is stdlib-only and cheaper than argh. + +Four of their findings are not defects in the implementation of a decision. They are +defects in the **decision**, and each one had a test suite and a gate that structurally +could not see it. That is what this ADR is for. + +## Decision + +### 1. A hyphenated positional is registered under its command-line name, as argh does + +Spec §9.3 permitted exactly one divergence and asserted it was invisible: argh writes +`add_argument('project-dir')`, producing a `dest` no Python call can use; cw wrote +`add_argument('project_dir', metavar='project-dir')` and needed no repair. + +It is not invisible. **argparse reads that one string twice** — once as the name displayed +in `usage:` and `--help` (`HelpFormatter._metavar_formatter`, where `metavar` beats +`choices`) and once as the name in `error: argument ...` (`_get_action_name`, where +`metavar` beats `dest`). A synthesised `metavar` wins the second reading and loses the +first, so a hyphenated positional carrying `choices` printed + +``` +usage: prog [-h] project-dir # cw +usage: prog [-h] {a,b} # argh +``` + +with an identical error message — which is why every error-level test agreed. + +cw now registers the hyphenated name and renames the `dest` back on the way into the call +(`ArgSpec.argparse_dest` → `_Stash.renames` → `_call_args`, one dictionary lookup in one +place). There is no permitted divergence left, and `tests/argh_parity/harness.py` no longer +forgives anything: `dest` and `metavar` are compared as they are. + +**Why the gate could not see it.** Every `choices` case in both corpora was on an *option*, +and every `Literal`-annotated positional had a one-word name. The blind spot was +shape-shaped, not case-count-shaped. `cw/tests/fixtures.py` gains `lacing convert-tree` +— a hyphenated positional with `Literal` choices, with a `--help` case — so +`python -m cw.testing parity` fails on a reintroduction (verified: `3 DIFFER`). + +### 2. Subparsers get cw's formatter; a parser somebody else built keeps its own + +`formatter_class` was defaulted only where `mk_parser` *builds* the parser. The 25 fleet +files that hold a parser object (`ArghParser()`, or `argparse.ArgumentParser()` then +`add_commands`) got argparse's stock formatter, so the advertised one-line migration +changed `--help` for every one of them: `-` became `None`, `'0.0.0.0'` became `0.0.0.0`, +and a multi-paragraph docstring was reflowed into one. + +The rule is argh's, read off argh 0.31.3 rather than guessed: + +| built how | argh's formatter | cw's | +|---|---|---| +| `ArghParser(...)` | set in `__init__` | set in `__init__` | +| `argparse.ArgumentParser()` + `set_default_command` | **untouched** | **untouched** | +| `argparse.ArgumentParser()` + `add_commands`, root | **untouched** | **untouched** | +| ... its subparsers | `PARSER_FORMATTER`, unconditionally | `cw.ArghHelpFormatter`, when the parent still carries argparse's stock one | + +The obvious fix — promote the formatter in `set_default_command` too — is **wrong**, and +the differential caught it: argh leaves the root alone there, so promoting it would have +removed one divergence by adding another. cw promotes only the stock formatter rather than +overriding unconditionally, so an explicit `formatter_class=` still means what it says. + +`tests/argh_parity/test_compat_parity.py` is the test that should have existed: it renders +help through every `cw.compat` entry point and diffs it against live argh, root and +subparsers alike. The missing line was only the first casualty of the missing differential. + +### 3. `replay` never reports "identical" about a `--help` body that moved + +Tier 3 snapshots the `--help` body and asserts only the normalised `usage:` line, because +`--help` wraps to `COLUMNS` and because CPython itself rewrites it (3.13 renders +`-i, --ignore VALUE` where 3.12 rendered `-i VALUE, --ignore VALUE`). That is still right +for `parity`, which replays committed goldens across a version matrix. + +But a change of *formatter* moves only the help column and the description block, so §1 and +§2 above were both invisible to the tool cw ships for exactly this purpose: `replay` printed +`6/6 identical` on a migration whose `--help` visibly changed. + +`replay` now compares the body through `normalise_help` — paragraph structure kept, wrapping +collapsed, therefore width-independent — and reports a case whose body moved as the +non-fatal status **`help-differs`**. `--strict-help` makes it fatal; `diff-help` still prints +it unnormalised for a human. The gate never *fails* on a rewrap, and it never *lies* about a +reflow. + +### 4. Errors at parser-construction time name cw, the function and the parameter + +argparse refuses an `add_argument` call with `ArgumentError`, `ValueError` **or** +`TypeError`; cw caught the first. So `config=` — a new surface with no argh equivalent, and +therefore the likeliest place a migrating repo errs — produced bare messages naming neither +cw nor the function nor the parameter, which is strictly worse than the argh being replaced. +The handler is argh's own scope now (`except Exception`), keeping the +`GrammarError: {func}: cannot add {param!r} as {flags}: {reason}` form that ADR-0005's +rollback transcript already showed a user seeing. A leading-underscore parameter — the argh +footgun cw reproduces on purpose — gets an extra sentence naming `cw.HIDE`. + +## Also changed, without amending a decision + +- **Two commands may not derive the same name.** argh raises `conflicting subparser`; cw + kept the last one, so `dispatch([run_a, run_b])` ran the wrong function and returned 0. + Now `CommandTreeError`, naming both callables. +- **`@arg(..., completer=...)` and `@arg(..., dest=...)` work**, which they must: cw is + argparse-based *specifically* so argcomplete keeps working for the ten fleet files marked + `# PYTHON_ARGCOMPLETE_OK`, and the shim those repos migrate through could not express a + completer at all. `completer` is an `ArgSpec` field assigned to the created action, as + argh does; `dest` decides which parameter a declaration names, as argh does. +- **`cw.MODERN`'s `Enum` help advertises what the converter accepts.** `choices` must hold + converted values, so it holds members, which render as `{Col.RED,Col.BLUE}` — tokens the + converter rejects. `metavar` carries the member names. +- **`BoundKeywordWarning`** is its own category, names the command whose `config` key closes + it, and carries no `id()`. `CW_QUIET=1` silences it for a console script with nowhere to + put a `filterwarnings` line — the previous advice (`cw.HIDE`) silenced it by *removing a + flag argh exposed*, which is not the same thing. +- **`egress=` on `mk_parser`** now says which call carries that seam instead of listing + `argparse.ArgumentParser`'s parameters. ADR-0001's seam table gains the row that says + where each seam lives; the seams themselves are unchanged. +- **`cw.resolve_object` is not promoted to the package root.** Zero call sites in cw or the + fleet, an uncovered body, and a TODO saying to merge it away. It stays at + `cw.resolution.resolve_object`, where 0.0.15 already shipped it; v1 does not commit to it + at the facade. `cw.CommandTreeError`, `cw.IngressError` and `cw.BoundKeywordWarning` are + exported instead — they are raised through the public entry points and were not catchable + by name. +- **CI installs `cw[test]`.** `[tool.wads.ci.install] extras = "test"` was missing, so wads' + reusable workflow ran a bare `uv pip install -e .` and three shipped `cw.resolution` items + failed on a missing `i2`. Two in-repo comments asserted the opposite and were wrong. + +## Consequences + +- The parity corpus is 8 shapes / **137** cases. ADR-0005's rollback transcript is otherwise + unchanged: the same mutation still produces `36 DIFFER`, re-verified. +- Spec §9.3's "one permitted divergence" is retired. cw's grammar has **no** divergence from + argh 0.31.3 that any test forgives. +- The lesson worth keeping is not any of the four fixes. It is that all four hid in the same + place: a suite that asserted *behaviour* everywhere and *rendering* nowhere. Three of them + were found by writing a differential that renders. Adding a case to a corpus is cheap; + noticing that a corpus has a shape-shaped hole is not, and the only defence found so far + is an adversary with a different harness. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1d65f08..08ee919 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -1,6 +1,6 @@ # Architecture decision records -Six decisions, written while cw v1 was built, in the Nygard format used across the fleet +Seven decisions, written while cw v1 was built, in the Nygard format used across the fleet (`docs/adr/NNNN-slug.md`, immutable once accepted; change one by writing a new ADR that supersedes it, never by editing an accepted **Decision** section in place). @@ -12,6 +12,7 @@ supersedes it, never by editing an accepted **Decision** section in place). | [0004](0004-grammar-errata.md) | Grammar errata: `group_kwargs`, Mapping-key naming, MODERN's help column | [#11](https://github.com/i2mint/cw/issues/11) | | [0005](0005-release-and-rollback-policy.md) | Release, pinning and rollback policy for the ~34 repos that will depend on cw | [#12](https://github.com/i2mint/cw/issues/12) | | [0006](0006-the-v1-cut-list.md) | The v1 cut list: what cw deliberately does **not** ship, and where each comes back | [#13](https://github.com/i2mint/cw/issues/13) | +| [0007](0007-what-the-adversarial-review-changed.md) | What three adversarial reviews changed: the "invisible" positional divergence, the formatter rule, the gate's blind spot | [#25](https://github.com/i2mint/cw/issues/25) | Read them in order. 0001 is the one that must outlive the session: it is the budget every later addition is spent against. diff --git a/pyproject.toml b/pyproject.toml index 305314a..e929c1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,8 +53,13 @@ resource = [ completion = [ "argcomplete>=3", ] -# What CI installs. Deliberately NO argh: `python -m cw.testing parity` -- the definition -# of v1 -- runs against committed goldens and must prove it needs nothing but cw. The +# The extra CI installs -- and CI really does install it, because +# `[tool.wads.ci.install].extras` below says so. Without that section wads' reusable +# workflow runs a bare `uv pip install -e .` and three shipped `cw.resolution` tests fail +# on a missing i2. +# +# Deliberately NO argh: `python -m cw.testing parity` -- the definition of v1 -- runs +# against committed goldens and must prove it needs nothing but cw. The # `tests/argh_parity` differential (and the two tests outside it that share its corpus) # skip themselves when argh is absent. # @@ -133,6 +138,12 @@ doctest_optionflags = [ [tool.wads.ci] project_name = "" +# What `uv pip install -e ".[...]"` gets in CI. wads' reusable workflow reads this key +# (`wads/ci_config.py:install_extras`); with no section it installs the package bare, and +# `cw[resource]`'s i2 is then missing for `cw.resolution`'s test and two doctests. +[tool.wads.ci.install] +extras = "test" + [tool.wads.ci.commands] pre_test = [] test = [] diff --git a/tests/argh_parity/conftest.py b/tests/argh_parity/conftest.py index 5ff60f6..21f7b85 100644 --- a/tests/argh_parity/conftest.py +++ b/tests/argh_parity/conftest.py @@ -1,6 +1,7 @@ """Skip the live-argh differential when argh is not installed. -`argh` is a **dev** extra, never a test one: cw's CI installs `cw[test]`, which has no argh +`argh` is a **dev** extra, never a test one: cw's CI installs `cw[test]` +(`[tool.wads.ci.install] extras = "test"`), which has no argh in it, precisely so that `python -m cw.testing parity` proves it needs nothing but cw. This directory is the other half of the story -- it builds the same parser with argh and with cw and diffs them, which of course needs argh -- so it removes itself rather than erroring at diff --git a/tests/argh_parity/corpus.py b/tests/argh_parity/corpus.py index 9f80e23..566ed5a 100644 --- a/tests/argh_parity/corpus.py +++ b/tests/argh_parity/corpus.py @@ -210,6 +210,23 @@ def declared_extra_into_kwargs(alpha, **kwargs): """argh's rule: an override naming no parameter is legal iff `**kwargs` exists.""" +def hyphenated_positional_with_literal(project_dir: Literal["src", "dist"]): + """The case a `metavar`-based dest repair renders WRONG, in `usage:` and in `--help`. + + argparse reads a positional's registered name twice -- as the displayed name and as the + name in `error: argument ...` -- and `metavar` wins only the second. So a hyphenated + positional carrying `choices` printed `project-dir` where argh printed `{src,dist}`, + with an identical error message, which is why no error-level test could see it. Both + corpora had `choices` on OPTIONS only, and every `Literal` positional had a one-word + name; this case is both at once. + """ + + +@declare("project_dir", choices=["src", "dist"]) +def hyphenated_positional_with_declared_choices(project_dir): + """The same shape reached through the decorator, with no annotation involved.""" + + def many_parameters( pkg_dir=".", project_name="", @@ -283,6 +300,11 @@ def many_parameters( Case("declared_falsy_nargs", declared_falsy_nargs), Case("declared_optional_positional", declared_optional_positional), Case("declared_extra_into_kwargs", declared_extra_into_kwargs), + Case("hyphenated_positional_with_literal", hyphenated_positional_with_literal), + Case( + "hyphenated_positional_with_declared_choices", + hyphenated_positional_with_declared_choices, + ), Case("many_parameters", many_parameters), ] diff --git a/tests/argh_parity/harness.py b/tests/argh_parity/harness.py index 81b0fbd..2b7bc09 100644 --- a/tests/argh_parity/harness.py +++ b/tests/argh_parity/harness.py @@ -10,14 +10,18 @@ in order; * the rendered **`--help`** text, byte for byte, at a pinned terminal width. -Exactly one divergence is normalised away, the one spec section 9.3 permits, and it is -invisible to a user: argh registers a hyphenated positional as -`add_argument('project-dir')`, giving a `dest` with a hyphen in it that no Python call can -use, then repairs it downstream; cw registers -`add_argument('project_dir', metavar='project-dir')`. `usage:`, `--help` and argparse's -error messages come out identical, and `normalise_action` compares the name the user sees. -Nothing else is forgiven -- `type`, `nargs`, `const`, `choices`, `default`, `required`, -`help` and the action class are compared as they are. +**Nothing is forgiven.** Every field is compared as it is -- `dest`, `metavar`, `type`, +`nargs`, `const`, `choices`, `default`, `required`, `help` and the action class. + +This file used to forgive one divergence, the one spec section 9.3 permitted and asserted +was invisible: argh registers a hyphenated positional as `add_argument('project-dir')`, +giving a `dest` no Python call can use, and cw registered +`add_argument('project_dir', metavar='project-dir')` instead. It was not invisible. argparse +reads that one string twice -- as the displayed name AND as the name in `error: argument +...` -- and a `metavar` wins only the second, so a hyphenated positional carrying `choices` +printed `project-dir` where argh printed `{a,b}`. cw now registers the hyphenated name and +renames the `dest` back on the way into the call, so there is no divergence left to forgive +and this docstring is the only trace of it. """ import argparse @@ -35,8 +39,7 @@ HELP_COLUMNS = "100" #: Every ``argparse.Action`` field the diff looks at. ``dest`` and ``metavar`` are in the -#: list on purpose: they are where the one permitted divergence shows up, and -#: :func:`normalise_action` is the only place that is allowed to forgive it. +#: list on purpose: they are where cw's one former divergence used to hide. ACTION_FIELDS = ( "dest", "option_strings", @@ -77,7 +80,7 @@ def cw_parser(func, *, convention=ARGH, config=None, prog="prog"): def normalise_action(action: argparse.Action) -> Dict[str, Any]: - """One action as a comparable dict, with the permitted divergences collapsed.""" + """One action as a comparable dict. Nothing is collapsed; see the module docstring.""" row = { "dest": action.dest, "option_strings": tuple(action.option_strings), @@ -91,11 +94,6 @@ def normalise_action(action: argparse.Action) -> Dict[str, Any]: "choices": action.choices, "metavar": action.metavar, } - if not action.option_strings: - # Spec 9.3: a positional's CLI name is `dest` in argh and `metavar` in cw. Compare - # the name the user actually sees, which is what both spellings produce. - row["dest"] = (action.metavar or action.dest).replace("_", "-") - row["metavar"] = None return row diff --git a/tests/argh_parity/test_compat_parity.py b/tests/argh_parity/test_compat_parity.py new file mode 100644 index 0000000..4305758 --- /dev/null +++ b/tests/argh_parity/test_compat_parity.py @@ -0,0 +1,215 @@ +"""`cw.compat` renders `--help` exactly as argh does, on every parser-building path. + +The grammar differential next door builds parsers through `cw.mk_parser`, which defaults +`formatter_class`. The 25 fleet files that hold a parser object do not: they write +`ArghParser()` or `argparse.ArgumentParser()` and then `add_commands` / `set_default_command` +on it. That path had no formatter default, so the one-line migration this shim exists for +changed `--help` for every one of them -- a `None` default printing `None` where argh +printed `-`, a string default losing its quotes, and a multi-paragraph docstring reflowed +into one -- and 805 green tests plus a green parity gate saw none of it, because no test +here had ever rendered help. + +This file is that test. It compares the **rendered** help of every `cw.compat` entry point +against live argh's, byte for byte, root parser and subparsers alike. +""" + +import argparse +import io +from contextlib import redirect_stdout + +import pytest + +argh = pytest.importorskip("argh") + +from argh.assembling import NameMappingPolicy # noqa: E402 + +import cw # noqa: E402 +from cw import compat # noqa: E402 + +POLICY = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + + +@pytest.fixture(autouse=True) +def _quiet(monkeypatch): + monkeypatch.setenv(compat.QUIET_ENV, "1") + monkeypatch.setenv("COLUMNS", "90") + + +def serve(host=None, port=8080, tag=", "): + """Serve it. + + Second paragraph, deliberately separate. It is here because argh's formatter is a + RawDescriptionHelpFormatter and argparse's stock one is not. + """ + + +def leaf(k=1): + """A leaf command.""" + + +def render(parser): + buffer = io.StringIO() + with redirect_stdout(buffer): + parser.print_help() + return buffer.getvalue() + + +def subparser(parser, name): + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + return action.choices[name] + raise AssertionError(f"no subparser {name!r}") + + +def test_the_case_this_file_exists_for_is_visible_at_all(): + """A guard on the guard: stock argparse really does render these three things + differently, so a green test below means the default is right and not that the + difference is unobservable.""" + stock = argparse.ArgumentParser(prog="demo") + compat.set_default_command(stock, serve) + stock.formatter_class = argparse.HelpFormatter + assert "None" in render(stock) and "-\n" not in render(stock) + + +class TestArghParser: + """The 27-call-site path: `ArghParser()` and then bind.""" + + def test_it_defaults_the_formatter(self): + assert compat.ArghParser(prog="x").formatter_class is cw.ArghHelpFormatter + + def test_set_default_command_help_is_identical(self): + theirs = argh.ArghParser(prog="demo") + argh.set_default_command(theirs, serve, name_mapping_policy=POLICY) + mine = compat.ArghParser(prog="demo") + compat.set_default_command(mine, serve) + assert render(mine) == render(theirs) + + def test_add_commands_help_is_identical_root_and_leaf(self): + theirs = argh.ArghParser(prog="demo") + argh.add_commands(theirs, [serve, leaf], name_mapping_policy=POLICY) + mine = compat.ArghParser(prog="demo") + compat.add_commands(mine, [serve, leaf]) + assert render(mine) == render(theirs) + assert render(subparser(mine, "serve")) == render(subparser(theirs, "serve")) + + def test_a_group_is_identical_too(self): + theirs = argh.ArghParser(prog="demo") + argh.add_commands( + theirs, + [leaf], + group_name="archive", + group_kwargs={"title": "Archive ops"}, + name_mapping_policy=POLICY, + ) + mine = compat.ArghParser(prog="demo") + compat.add_commands( + mine, [leaf], group_name="archive", group_kwargs={"title": "Archive ops"} + ) + assert render(mine) == render(theirs) + assert render(subparser(mine, "archive")) == render( + subparser(theirs, "archive") + ) + + def test_an_explicit_formatter_is_never_overridden(self): + parser = compat.ArghParser( + prog="x", formatter_class=argparse.RawTextHelpFormatter + ) + assert parser.formatter_class is argparse.RawTextHelpFormatter + + +class TestAParserSomebodyElseBuilt: + """The other documented path: a plain `argparse.ArgumentParser`. + + argh applies its own formatter to every subparser it creates even under a stock root + parser, so cw promotes a stock formatter rather than propagating it. + """ + + def test_set_default_command_leaves_the_root_alone_because_argh_does(self): + """The one place the obvious fix would have been WRONG. + + `set_default_command` on a plain parser looks like it should get cw's formatter -- + but argh leaves the root's `formatter_class` untouched there, so promoting it would + have introduced a divergence while removing another. Verified against argh 0.31.3: + `argh.set_default_command(argparse.ArgumentParser(), f).formatter_class` is + `argparse.HelpFormatter`. + """ + theirs = argparse.ArgumentParser(prog="demo") + argh.set_default_command(theirs, serve, name_mapping_policy=POLICY) + mine = argparse.ArgumentParser(prog="demo") + cw.set_default_command(mine, serve) + assert mine.formatter_class is argparse.HelpFormatter + assert render(mine) == render(theirs) + + def test_add_commands_promotes_the_subparsers(self): + theirs = argparse.ArgumentParser(prog="demo") + argh.add_commands(theirs, [serve, leaf], name_mapping_policy=POLICY) + mine = argparse.ArgumentParser(prog="demo") + cw.add_commands(mine, [serve, leaf]) + assert mine.formatter_class is argparse.HelpFormatter # root untouched, as argh + assert subparser(mine, "serve").formatter_class is cw.ArghHelpFormatter + assert render(mine) == render(theirs) + assert render(subparser(mine, "serve")) == render(subparser(theirs, "serve")) + + def test_an_explicit_root_formatter_still_reaches_the_subparsers(self): + """cw promotes only the stock formatter. argh overrides unconditionally, which + would throw away an explicit `formatter_class=`; no fleet call site passes one to a + parser it then hands to `add_commands`, and "explicit wins" is the rule cw states + everywhere else.""" + mine = argparse.ArgumentParser( + prog="demo", formatter_class=argparse.RawTextHelpFormatter + ) + cw.add_commands(mine, [leaf]) + assert subparser(mine, "leaf").formatter_class is argparse.RawTextHelpFormatter + + +class TestArgDeclarations: + """`cw.compat.arg` against `argh.arg`, for the two keywords that are not + `add_argument` keywords.""" + + def test_completer_reaches_the_action(self): + def theirs(alpha=1): ... + + def mine(alpha=1): ... + + completer = lambda **kwargs: ["x"] # noqa: E731 + theirs = argh.arg("--alpha", completer=completer)(theirs) + mine = compat.arg("--alpha", completer=completer)(mine) + their_parser = argparse.ArgumentParser( + prog="p", formatter_class=argh.PARSER_FORMATTER + ) + argh.set_default_command(their_parser, theirs, name_mapping_policy=POLICY) + my_parser = cw.mk_parser(mine, prog="p") + assert getattr(their_parser._actions[-1], "completer") is completer + assert getattr(my_parser._actions[-1], "completer") is completer + assert render(my_parser) == render(their_parser) + + def test_dest_names_the_parameter(self): + def theirs(alpha=1, beta=2): ... + + def mine(alpha=1, beta=2): ... + + theirs = argh.arg("--al", dest="alpha", help="aliased")(theirs) + mine = compat.arg("--al", dest="alpha", help="aliased")(mine) + their_parser = argparse.ArgumentParser( + prog="p", formatter_class=argh.PARSER_FORMATTER + ) + argh.set_default_command(their_parser, theirs, name_mapping_policy=POLICY) + assert render(cw.mk_parser(mine, prog="p")) == render(their_parser) + + +def test_two_commands_with_one_name_are_refused_by_both(): + """argh raises `conflicting subparser`; cw used to keep the last one silently.""" + + def first(): + """First.""" + + def second(): + """Second.""" + + second.__name__ = "first" + their_parser = argparse.ArgumentParser(prog="p") + with pytest.raises(Exception) as their_error: + argh.add_commands(their_parser, [first, second], name_mapping_policy=POLICY) + assert "conflicting" in str(their_error.value) + with pytest.raises(cw.CommandTreeError): + cw.mk_parser([first, second], prog="p") diff --git a/tests/argh_parity/test_parity.py b/tests/argh_parity/test_parity.py index c8d6b21..4f58143 100644 --- a/tests/argh_parity/test_parity.py +++ b/tests/argh_parity/test_parity.py @@ -155,21 +155,40 @@ def test_parsed_values_match_argh(name, func, argv, expected): assert from_argh[key] == value, f"argh parsed {key}={from_argh[key]!r}" -def test_hyphenated_positional_is_reachable_by_a_python_name(): - """The permitted divergence, stated as a property rather than hidden by the diff. - - argh's `dest` is `'project-dir'`, which no Python call can use; cw's is - `'project_dir'`, which is the parameter's own name. Both render `project-dir`. +def test_hyphenated_positional_registers_exactly_as_argh_does(): + """There is no permitted divergence here any more: the `dest`s are the same too. + + cw used to register `add_argument('project_dir', metavar='project-dir')` to avoid a + `dest` no Python call can use. argparse reads that one string twice -- as the displayed + name AND as the name in its error messages -- and a `metavar` wins only the second, + which silently turned `{a,b}` into `project-dir` for a hyphenated positional carrying + `choices`. cw now registers the hyphenated name, exactly as argh does, and renames the + `dest` back on the way into the call. """ argh_ns = vars(argh_parser(corpus.hyphenated_positional).parse_args(["here"])) argh_ns.pop("function") # argh stashes the endpoint in the namespace; cw does not cw_ns = vars(cw_parser(corpus.hyphenated_positional).parse_args(["here"])) assert argh_ns == {"project-dir": "here", "dry_run": False} - assert cw_ns == {"project_dir": "here", "dry_run": False} + assert cw_ns == argh_ns assert "project-dir" in argh_parser(corpus.hyphenated_positional).format_usage() assert "project-dir" in cw_parser(corpus.hyphenated_positional).format_usage() +def test_the_hyphenated_dest_is_renamed_back_before_the_call(): + """...and the function still receives its argument under its own parameter name.""" + import io + + import cw + + def quickstart(project_dir, *, dry_run=False): + return f"{project_dir}/{dry_run}" + + assert cw.dispatch(quickstart, ["here"], standalone=False) == "here/False" + out = io.StringIO() + assert cw.dispatch(quickstart, ["here"], out=out) == 0 + assert out.getvalue() == "here/False\n" + + def test_missing_positional_error_names_it_the_same_way(): """The metavar has to survive into argparse's own error text, not just `--help`.""" for parser in ( diff --git a/tests/test_cli.py b/tests/test_cli.py index 5270184..c823883 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,12 +17,13 @@ import pathlib import re import sys +import warnings import pytest import cw from cw.cli import RESERVED_DEST, add_commands, enable_completion, set_default_command -from cw.grammar import GrammarError +from cw.grammar import ArgSpec, GrammarError def echo(word, *, loud=False): @@ -476,3 +477,190 @@ def counted(): out = io.StringIO() cw.run(parser, ["modern", "counted"], out=out) assert out.getvalue() == "0\n1\n" + + +# ======================================================================================= +# add_argument failures name cw, the function and the parameter +# ======================================================================================= + + +class TestAnAddArgumentFailureIsInformative: + """argparse refuses an argument in three different exception types. + + None of its messages names the function, the parameter or the flags. argh wraps all of + them (`AssemblingError: {func}: cannot add {param} as {flags}: {reason}`), and cw must + not be *worse* than the library it replaces at the one moment a migration goes wrong. + Only `argparse.ArgumentError` was caught before, so `config=` -- a brand new surface + with no argh equivalent, and therefore the likeliest place to make a mistake -- raised + bare `ValueError`s and `TypeError`s. + """ + + @staticmethod + def leaf(alpha=1): + """A command.""" + + @pytest.mark.parametrize( + "leaf_config, underlying", + [ + ({"bogus": 1}, TypeError), # unknown add_argument keyword + ({"nargs": 2, "metavar": ("A",)}, ValueError), # metavar/nargs mismatch + ({"action": "nope"}, ValueError), # unknown action + ({"type": "notacallable"}, ValueError), # non-callable type + ], + ) + def test_every_argparse_refusal_is_wrapped(self, leaf_config, underlying): + with pytest.raises(cw.GrammarError) as error: + cw.mk_parser(self.leaf, config={"alpha": leaf_config}, prog="p") + message = str(error.value) + assert "leaf: cannot add 'alpha' as -a/--alpha" in message + assert isinstance(error.value.__cause__, underlying) + + def test_a_duplicate_flag_is_wrapped_too(self): + """The `argparse.ArgumentError` case, which was the only one caught before.""" + + def two(pool=1, port=2): ... + + with pytest.raises(cw.GrammarError) as error: + cw.mk_parser( + two, config={"port": {"flags": ["-p", "--port"]}}, prog="p" + ) if False else cw.mk_parser( + two, + config={"pool": {"flags": ["--pool"]}, "port": {"flags": ["--pool"]}}, + prog="p", + ) + assert "cannot add 'port'" in str(error.value) + assert isinstance(error.value.__cause__, argparse.ArgumentError) + + def test_the_underscore_footgun_names_its_documented_fix(self): + """A leading-underscore parameter hyphenates into `--` / `---pool`, which argparse + rejects. cw reproduces that argh bug on purpose (D2), so the message has to carry + the workaround `cw.HIDE` that the grammar's docstring promises.""" + + def serve(host="0.0.0.0", _pool=4): ... + + with pytest.raises(cw.GrammarError) as error: + cw.mk_parser(serve, prog="p") + message = str(error.value) + assert "serve: cannot add '_pool' as --/---pool" in message + assert "cw.HIDE" in message + + def test_and_hiding_it_really_does_fix_it(self): + def serve(host="0.0.0.0", _pool=4): + return f"{host}/{_pool}" + + assert ( + cw.dispatch( + serve, [], config={"_pool": cw.HIDE}, standalone=False, prog="p" + ) + == "0.0.0.0/4" + ) + + +class TestTheBoundKeywordWarning: + """A `functools.partial`'s pre-bound keyword still showing as a flag. + + It fires on every invocation of a CLI that has one -- `--help` included -- so its text + is part of the product: it must name the command whose config key closes it, carry no + `id()` (which would make it differ every run), and be silenceable without changing the + CLI. + """ + + @staticmethod + def packages(project=None, *, config_type="setup.cfg"): + """Packages.""" + + @property + def bound(self): + return functools.partial(self.packages, config_type="setup.cfg") + + def test_it_names_the_command_and_carries_no_address(self): + with pytest.warns(cw.BoundKeywordWarning) as caught: + cw.mk_parser({"packages-from-all": self.bound}, prog="p") + message = str(caught[0].message) + assert "config={'packages-from-all': {'config_type': cw.HIDE}}" in message + assert "0x" not in message + assert "packages" in message + + def test_a_single_command_needs_no_command_key(self): + with pytest.warns(cw.BoundKeywordWarning) as caught: + cw.mk_parser(self.bound, prog="p") + assert "config={'config_type': cw.HIDE}" in str(caught[0].message) + + def test_it_can_be_silenced_without_changing_the_parser(self, monkeypatch): + monkeypatch.setenv("CW_QUIET", "1") + with warnings.catch_warnings(): + warnings.simplefilter("error") + parser = cw.mk_parser({"go": self.bound}, prog="p") + assert "--config-type" in _sub(parser, "go").format_help() + + def test_filtering_the_category_works_too(self): + with warnings.catch_warnings(): + warnings.simplefilter("error") + warnings.filterwarnings("ignore", category=cw.BoundKeywordWarning) + cw.mk_parser({"go": self.bound}, prog="p") + + def test_hiding_the_parameter_still_silences_it(self): + with warnings.catch_warnings(): + warnings.simplefilter("error") + parser = cw.mk_parser( + {"go": self.bound}, config={"go": {"config_type": cw.HIDE}}, prog="p" + ) + assert "--config-type" not in _sub(parser, "go").format_help() + + +class TestArgcompleteCompleters: + """`completer=` is argcomplete's per-argument hook, and not an `add_argument` keyword. + + cw is argparse-based *specifically* so that argcomplete keeps working for the ten fleet + files marked `# PYTHON_ARGCOMPLETE_OK`; a shim through which those repos migrate must + be able to express a completer, and it used to raise a raw argparse `TypeError`. + """ + + def test_a_config_leaf_carries_it_to_the_action(self): + def serve(host="0.0.0.0"): ... + + completer = lambda **kwargs: ["localhost"] # noqa: E731 + parser = cw.mk_parser( + serve, config={"host": {"completer": completer}}, prog="p" + ) + assert parser._actions[-1].completer is completer + + def test_it_is_not_passed_to_add_argument(self): + """The failure mode: `_StoreAction.__init__() got an unexpected keyword + argument 'completer'`, with no mention of cw anywhere in it.""" + spec = ArgSpec("host", ["--host"], extra={}) + spec.completer = print + assert "completer" not in spec.add_argument_kwargs() + + +def _sub(parser, name): + """The subparser called ``name``.""" + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + return action.choices[name] + raise AssertionError(f"no subparser {name!r}") + + +class TestASeamNamedOnTheWrongCall: + """`egress=` is a real seam; it is just not a `mk_parser` keyword. + + ADR-0001 says each seam is one keyword argument, and it is -- but not every seam is on + every entry point, because `egress` runs after the call and `mk_parser` never calls + anything. Blaming argparse and listing argparse's parameters was true and useless. + """ + + def test_egress_on_mk_parser_names_the_right_call(self): + with pytest.raises(TypeError) as error: + cw.mk_parser(echo, egress=lambda *a, **k: 0) + message = str(error.value) + assert "cw.run and cw.dispatch" in message + assert "dataclasses.replace(cw.ARGH, egress=" in message + + def test_ingress_says_there_is_no_such_keyword_anywhere(self): + with pytest.raises(TypeError) as error: + cw.mk_parser(echo, ingress=print) + assert "no `ingress=` keyword" in str(error.value) + + def test_an_ordinary_typo_still_blames_argparse(self): + with pytest.raises(TypeError, match="argparse.ArgumentParser accepts"): + cw.mk_parser(echo, prgo="x") diff --git a/tests/test_commands.py b/tests/test_commands.py index 6e5be65..8d86bbd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -7,6 +7,7 @@ """ import functools +import io import types import pytest @@ -181,3 +182,74 @@ def test_a_missing_colon_is_an_informative_error(self): def test_the_discriminator_is_the_value(): assert is_command(ls) and is_command(functools.partial(ls)) and is_command(Runner()) assert not is_command([ls]) and not is_command({"a": ls}) and not is_command("ls") + + +# ======================================================================================= +# Two commands may not share a derived name +# ======================================================================================= + + +class TestNameCollisionsAreRefused: + """argh raises `ArgumentError: conflicting subparser`. cw used to keep the last one. + + Silently losing a command is worse than the crash it replaces: `dispatch([run_a, + run_b])` where the two share a `__name__` returned 0 and ran the wrong function, with + no warning and no row in `--help`. + """ + + @staticmethod + def _two_functions_called_first(): + def first(): + """First.""" + return "FIRST" + + def second(): + """Second.""" + return "SECOND" + + second.__name__ = "first" + return first, second + + def test_an_iterable_refuses_it(self): + first, second = self._two_functions_called_first() + with pytest.raises(CommandTreeError) as error: + commands_from([first, second]) + message = str(error.value) + assert "'first'" in message + assert message.count("first") >= 2 and "second" in message + + def test_a_mapping_refuses_it_after_hyphenation(self): + """`git_ops` and `git-ops` derive the same command name under ARGH.""" + + def one(): + """One.""" + + def two(): + """Two.""" + + with pytest.raises(CommandTreeError): + commands_from({"git_ops": one, "git-ops": two}) + + def test_a_module_whose_names_collide_after_hyphenation_refuses_it(self): + """`__all__ = ['git_ops', 'git-ops']` derives one command name from two + attributes. (A name listed twice is not a collision: it is the same object.)""" + module = types.ModuleType("toy") + module.__all__ = ["do_it", "do-it"] + module.do_it = lambda: None + setattr(module, "do-it", lambda: None) + with pytest.raises(CommandTreeError): + commands_from(module) + + def test_dispatch_refuses_it_rather_than_running_the_last_one(self): + first, second = self._two_functions_called_first() + with pytest.raises(CommandTreeError): + cw.dispatch([first, second], ["first"], out=io.StringIO(), prog="p") + + def test_two_groups_may_still_hold_the_same_command_name(self): + """The point of grouping: `xa list` and `xa archive list` both exist.""" + + def ls(): + """List.""" + + tree = commands_from({"list": ls, "archive": {"list": ls}}) + assert sorted(tree) == ["archive", "list"] diff --git a/tests/test_compat.py b/tests/test_compat.py index e2ee3b3..6f9e31b 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -476,3 +476,34 @@ def test_actually_using_it_says_plainly_that_it_is_not_implemented(self): parser = compat.ArghParser(prog="x") with pytest.raises(NotImplementedError, match="no fleet call site passes it"): compat.add_commands(parser, [hello], func_kwargs={"help": "hi"}) + + +class TestTheImportFormsThatDoNotSurviveTheSwap: + """`from cw import compat as argh` is one line, and three spellings still break. + + Each one is a real fleet import form, and each used to fail with a bare error that did + not name the working replacement. The README's grep list carries the same three. + """ + + def test_compat_is_a_module_not_a_package(self): + with pytest.raises(ModuleNotFoundError): + __import__("cw.compat.assembling") + + @pytest.mark.parametrize( + "name, must_mention", + [ + ("assembling", "from cw.compat import NameMappingPolicy"), + ("interaction", "cw.compat.confirm"), + ("PARSER_FORMATTER", "cw.ArghHelpFormatter"), + ("expects_obj", "parse_args"), + ], + ) + def test_the_error_names_the_working_spelling(self, name, must_mention): + with pytest.raises(AttributeError) as error: + getattr(compat, name) + assert must_mention in str(error.value) + + def test_the_replacements_really_are_reachable(self): + assert compat.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + assert callable(compat.confirm) + assert cw.ArghHelpFormatter is not None diff --git a/tests/test_convention.py b/tests/test_convention.py index f318b77..77b2658 100644 --- a/tests/test_convention.py +++ b/tests/test_convention.py @@ -6,6 +6,7 @@ """ import dataclasses +import enum import io import pathlib @@ -158,3 +159,58 @@ def test_convention_imports_no_argparse(): """It has no formatter reference to place, which is what makes the guard honest.""" source = pathlib.Path(cw.convention.__file__).read_text() assert "import argparse" not in source + + +# ======================================================================================= +# MODERN's Enum support advertises what it accepts +# ======================================================================================= + + +class TestModernEnumChoices: + """The help column must not advertise tokens the converter rejects. + + `choices` has to hold the CONVERTED values -- argparse checks membership after `type=` + runs -- so it holds Enum members, which render as `{Col.RED,Col.BLUE}`. Those are + exactly the strings the converter does *not* accept. `metavar` decides what is + displayed, so it carries the member names. + """ + + class Colour(enum.Enum): + RED = "r" + BLUE = "b" + + @staticmethod + def paint(*, colour=None): + """Paint.""" + return colour + + def _parser(self): + def paint(*, colour: TestModernEnumChoices.Colour = self.Colour.RED): + return colour + + return cw.mk_parser(paint, convention=cw.MODERN, prog="p"), paint + + def test_the_usage_line_shows_the_member_names(self): + parser, _ = self._parser() + assert "{RED,BLUE}" in parser.format_usage() + + def test_and_a_displayed_token_round_trips(self): + _, paint = self._parser() + out = io.StringIO() + code = cw.dispatch( + paint, ["--colour", "RED"], out=out, convention=cw.MODERN, prog="p" + ) + assert (code, out.getvalue()) == (0, "Colour.RED\n") + + def test_a_value_still_works_too(self): + _, paint = self._parser() + assert ( + cw.dispatch( + paint, + ["--colour", "b"], + standalone=False, + convention=cw.MODERN, + prog="p", + ) + is self.Colour.BLUE + ) diff --git a/tests/test_grammar.py b/tests/test_grammar.py index aa70742..4a0b756 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -448,17 +448,26 @@ def test_convention_is_frozen_and_hashable(): # ArgSpec itself -def test_add_argument_args_repairs_a_hyphenated_positional(): +def test_add_argument_args_registers_a_positional_under_its_cli_name(): + """argh's spelling exactly: the hyphen goes into the `dest`, not into a `metavar`.""" assert ArgSpec("project_dir", ["project-dir"]).add_argument_args() == ( - ("project_dir",), - {"metavar": "project-dir"}, + ("project-dir",), + {}, ) assert ArgSpec("path", ["path"]).add_argument_args() == (("path",), {}) def test_add_argument_args_leaves_an_explicit_metavar_alone(): spec = ArgSpec("project_dir", ["project-dir"], extra={"metavar": "DIR"}) - assert spec.add_argument_args() == (("project_dir",), {"metavar": "DIR"}) + assert spec.add_argument_args() == (("project-dir",), {"metavar": "DIR"}) + + +def test_argparse_dest_names_the_namespace_key(): + assert ArgSpec("project_dir", ["project-dir"]).argparse_dest == "project-dir" + assert ( + ArgSpec("project_dir", ["-p", "--project-dir"]).argparse_dest == "project_dir" + ) + assert ArgSpec("x", ["--x"], extra={"dest": "y"}).argparse_dest == "y" def test_missing_fields_are_simply_not_passed(): diff --git a/tests/test_resolution.py b/tests/test_resolution.py index 763bd9a..cd22dc5 100644 --- a/tests/test_resolution.py +++ b/tests/test_resolution.py @@ -20,12 +20,25 @@ "parse_json_spec", "parse_spec_with_dot_path", "resolve_func_from_dot_path", - "resolve_object", "resolve_to_function", "resource_inputs", ) +def test_resolve_object_is_not_promoted_to_the_package_root(): + """It has zero call sites in cw and zero across the fleet, its body is uncovered, and + `cw/resolution.py:67` carries a TODO saying it should be merged away. + `architecture-first`'s pre-commit check 5 says an unreachable name is code written for + iteration 2. It stays where it has always been -- `cw.resolution` shipped it in 0.0.15 + -- but v1 does not commit to it at the facade, where nothing has ever asked for it. + """ + from cw import resolution + + assert callable(resolution.resolve_object) + assert not hasattr(cw, "resolve_object") + assert "resolve_object" not in cw.__all__ + + @pytest.mark.parametrize("name", PUBLIC_RESOLUTION_NAMES) def test_resolution_names_are_reachable_from_the_package_root(name): """`t/theremin` does ``from cw import resolve_to_function``. Keep that true.""" diff --git a/tests/test_testing.py b/tests/test_testing.py index b6aef45..530e592 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -268,7 +268,48 @@ def test_a_tier_three_help_change_is_NOT_a_failure(self, toy): path.write_text( TOY_CLI.replace('description="A toy."', 'description="Toy!"'), "utf-8" ) - assert all(r["status"] == "identical" for r in testing.replay(golden)) + results = testing.replay(golden) + assert not [r for r in results if r["status"] == "differs"] + testing.assert_replay(golden) # ... and it does not raise + + def test_but_it_is_no_longer_called_identical_either(self, toy): + """A changed `--help` body is reported as `help-differs`, not swallowed. + + The defect this closes: swapping a dispatcher for one with a different + `formatter_class` moves every default's rendering and every multi-paragraph + description, and touches neither the `usage:` line nor any exit code -- so `replay` + used to print `N/N identical` on a migration whose `--help` visibly changed. + """ + path, prog = toy + golden = testing.characterize(prog, TOY_CASES) + path.write_text( + TOY_CLI.replace('description="A toy."', 'description="Toy!"'), "utf-8" + ) + statuses = {r["status"] for r in testing.replay(golden)} + assert "help-differs" in statuses + assert "identical" in statuses # the non-help cases are still identical + + def test_strict_help_makes_it_a_failure(self, toy): + path, prog = toy + golden = testing.characterize(prog, TOY_CASES) + path.write_text( + TOY_CLI.replace('description="A toy."', 'description="Toy!"'), "utf-8" + ) + bad = [ + r + for r in testing.replay(golden, strict_help=True) + if r["status"] == "differs" + ] + assert bad and "help:" in bad[0]["diff"] + with pytest.raises(AssertionError): + testing.assert_replay(golden, strict_help=True) + + def test_a_pure_rewrap_is_not_reported(self, toy): + """`normalise_help` is width-independent, so COLUMNS alone never trips it.""" + _, prog = toy + golden = testing.characterize(prog, TOY_CASES) + results = testing.replay(golden, env={"COLUMNS": "40"}) + assert all(r["status"] == "identical" for r in results) def test_but_diff_help_reports_it_advisorily(self, toy): path, prog = toy From a07859e3917510729f448a732042c61a48fdb257 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:43:35 +0200 Subject: [PATCH 7/7] Make the suite green under CI's own flags, and on Windows Both were latent before this branch; the first CI run on it is what surfaced them. - wads' reusable workflow runs pytest with `-o doctest_optionflags='ELLIPSIS IGNORE_EXCEPTION_DETAIL'`, unconditionally, which DROPS the NORMALIZE_WHITESPACE that pyproject sets. Two grammar doctests wrapped their expected output and therefore only ever passed locally. One now carries an inline `# doctest: +NORMALIZE_WHITESPACE`; the other prints a line at a time, which reads better anyway. - Windows, three failures: * `cw.testing._as_command` shlex-split the command string in POSIX mode, which eats the backslashes out of `C:\py\python.exe` and leaves a command nothing can run -- surfacing as `FileNotFoundError: [WinError 2]` with no clue where it came from. Non-POSIX mode on `os.name == 'nt'`, with the quotes it keeps stripped back off, plus four tests that pin both platforms. * `cw.resolution.resolve_func_from_dot_path`'s doctest asserted `os.path.join` returns `'a/b'`. Verified with CI's exact flags: 872 passed, 2 skipped. Gate still identical. Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr --- cw/grammar.py | 12 +++++++----- cw/resolution.py | 4 ++-- cw/testing.py | 13 ++++++++++--- tests/test_testing.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/cw/grammar.py b/cw/grammar.py index 7dcb2ab..86f82b6 100644 --- a/cw/grammar.py +++ b/cw/grammar.py @@ -266,7 +266,8 @@ def from_override(cls, param_name: str, override: Mapping[str, Any]) -> "ArgSpec leaf being :data:`cw.HIDE`, which :func:`specs_for_function` handles before it gets here. - >>> ArgSpec.from_override('synth', {'flags': ['-s'], 'nargs': '?'}) + >>> ArgSpec.from_override( # doctest: +NORMALIZE_WHITESPACE + ... 'synth', {'flags': ['-s'], 'nargs': '?'}) ArgSpec(param_name='synth', flags=['-s'], required=cw.MISSING, default=cw.MISSING, nargs='?', extra={}, codec=None, hidden=False, completer=None) """ @@ -554,10 +555,11 @@ def infer_specs( >>> def f(path, *, verbose: bool = False, tags: list = None): ... ... - >>> [(s.param_name, s.flags, s.extra) for s in infer_specs(f)] - [('path', ['path'], {}), - ('verbose', ['-v', '--verbose'], {}), - ('tags', ['-t', '--tags'], {'nargs': '*'})] + >>> for spec in infer_specs(f): + ... print(spec.param_name, spec.flags, spec.extra) + path ['path'] {} + verbose ['-v', '--verbose'] {} + tags ['-t', '--tags'] {'nargs': '*'} """ convention = convention if convention is not None else _default_convention() decode = decode if decode is not None else convention.decode diff --git a/cw/resolution.py b/cw/resolution.py index ba78e78..c130be4 100644 --- a/cw/resolution.py +++ b/cw/resolution.py @@ -100,8 +100,8 @@ def resolve_func_from_dot_path(dot_path: str) -> Callable: Examples: >>> import os.path >>> join_func = resolve_func_from_dot_path('os.path.join') - >>> join_func('a', 'b') # doctest: +ELLIPSIS - 'a/b' + >>> join_func('a', 'b') == os.path.join('a', 'b') # a separator, whichever OS + True >>> len_func = resolve_func_from_dot_path('builtins.len') >>> len_func([1, 2, 3]) diff --git a/cw/testing.py b/cw/testing.py index 9fc1d9a..c95a7d7 100644 --- a/cw/testing.py +++ b/cw/testing.py @@ -497,15 +497,22 @@ def _flush(*streams) -> None: def _as_command(prog) -> list: """``prog`` as a command list, from either spelling. - A list is the cross-platform form and is taken as-is. A string is ``shlex``-split, - which is POSIX-only -- hence the list form, and hence this docstring. + A list is taken as-is and is the form to reach for when a path is involved. A string is + ``shlex``-split -- in POSIX mode on POSIX, and in non-POSIX mode on Windows, where + POSIX mode would silently eat the backslashes out of ``C:\\Python\\python.exe`` and + leave a command nothing can run. Non-POSIX mode keeps the quotes around a quoted token, + so they come off here. >>> _as_command('python -m cw') ['python', '-m', 'cw'] >>> _as_command(['python', '-m', 'cw']) ['python', '-m', 'cw'] """ - return shlex.split(prog) if isinstance(prog, str) else [str(part) for part in prog] + if not isinstance(prog, str): + return [str(part) for part in prog] + if os.name == "nt": + return [part.strip('"') for part in shlex.split(prog, posix=False)] + return shlex.split(prog) def _run_subprocess(command, argv, *, env, cwd, timeout) -> dict: diff --git a/tests/test_testing.py b/tests/test_testing.py index 530e592..2c3b8a3 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -687,3 +687,34 @@ def test_it_forgives_ONLY_those_two_and_still_sees_a_real_difference(self): ) != "" ) + + +class TestTheCommandStringIsSplitPerPlatform: + """`shlex.split` is POSIX-only, and a Windows command is nothing but backslashes. + + `characterize 'C:\\py\\python.exe C:\\repo\\tool.py'` used to become + `['C:pypython.exe', 'C:repotool.py']` -- a command nothing can run, reported as + `FileNotFoundError: [WinError 2]` with no clue where it came from. + """ + + def test_a_list_is_always_taken_as_is(self): + assert testing._as_command(["python", "-m", "cw"]) == ["python", "-m", "cw"] + + def test_posix_splits_posix(self, monkeypatch): + monkeypatch.setattr(testing.os, "name", "posix") + assert testing._as_command("python -m cw") == ["python", "-m", "cw"] + + def test_windows_keeps_its_backslashes(self, monkeypatch): + monkeypatch.setattr(testing.os, "name", "nt") + assert testing._as_command(r"C:\py\python.exe C:\repo\tool.py") == [ + r"C:\py\python.exe", + r"C:\repo\tool.py", + ] + + def test_windows_still_honours_quoting_for_a_path_with_a_space(self, monkeypatch): + monkeypatch.setattr(testing.os, "name", "nt") + assert testing._as_command(r'"C:\Program Files\py.exe" -m cw') == [ + r"C:\Program Files\py.exe", + "-m", + "cw", + ]