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..bb3c105 100644 --- a/README.md +++ b/README.md @@ -1,227 +1,488 @@ -# 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 -## Why CW? +def greet(name, *, loudly=False): + """Say hello to someone.""" + return f'HELLO {name}' if loudly else f'hello {name}' -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. +raise SystemExit(cw.dispatch(greet)) # that is the whole CLI +``` -Consider this example using `argh`: +```console +$ python greet.py world --loudly +HELLO world +``` -```python -import argh -from cw.resolution import resource_inputs, parse_ast_spec +`pip install cw` — no dependencies, MIT, and `import cw` costs stdlib only. -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! +## What it does -# With CW: This works seamlessly -function_store = { - 'double': lambda x: x * 2, - 'square': lambda x: x ** 2, - 'upper': str.upper -} +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. -wrapped_process = resource_inputs( - process_data, - resource={ - 'transform_func': { - 'func_key_and_kwargs': parse_ast_spec, - 'get_func': function_store.get - } - } -) +positional arguments: + name - -@argh.dispatch_command -def cli_process_working(data, transform_func, multiplier=1): - return wrapped_process(data, transform_func, multiplier) +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 ``` -Now you can call from the command line: -```bash -python script.py "hello" "upper()" --multiplier 3 -# Results in: "HELLOHELLOHELLO" +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 cw + +def add(a: int, b: int): + """Add two numbers.""" + return a + b -python script.py 5 "double()" --multiplier 2 -# Results in: 20 (5 * 2 * 2) +def ls(path='.', *, long=False): + """List a directory.""" + return [f'{path}/one', f'{path}/two'] + +COMMANDS = {'add': add, 'list': ls, 'git-ops': {'add': add}} +raise SystemExit(cw.dispatch(COMMANDS, prog='tool')) ``` -## Core Components +```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 -### Function Resolution (`cw.resolution`) +options: + -h, --help show this help message and exit + +$ tool add 2 3 +5 +$ tool list -p /tmp +/tmp/one +/tmp/two +``` -The resolution module provides utilities to convert string specifications into callable functions: +Six forms of `obj`, each decided by the **value**, never by a string DSL: -- **`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 +| `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 | -### Resource Inputs Decorator +A name from a key or `__all__` beats `__name__`, and is then hyphenated: +`{'parse_pth_paths': f}` gives you `parse-pth-paths`. -The `resource_inputs` decorator wraps functions to automatically resolve specified string parameters into actual objects: +### What a command's return value does + +`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. ```python -from cw.resolution import resource_inputs, parse_ast_spec +>>> 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 +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. -### Basic Function Resolution +Two leaf values are not `add_argument` kwargs: + +- `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 - -```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).) -### Dot Path Parser +**Bind a function to a parser you built yourself:** ```python ->>> from cw.resolution import parse_spec_with_dot_path ->>> parse_spec_with_dot_path('os.path.join') -('os.path.join', {}) +>>> 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 +``` + +**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`: ->>> parse_spec_with_dot_path('len') -('len', {}) +```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 ``` -### JSON Parser +`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 ->>> from cw.resolution import parse_json_spec ->>> parse_json_spec('{"func": "len", "params": {}}') -('len', {}) +>>> 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: + +```python +>>> 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: + +```python +>>> cw.dispatch(greet, ['world', '--loudly'], standalone=False) +'HELLO world' +``` + +**Change how results are printed** — `egress=` is one keyword: + +```python +>>> 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. ->>> parse_json_spec('{"func": "str.replace", "params": {"old": "a", "new": "b"}}') -('str.replace', {'old': 'a', 'new': 'b'}) +**Step 1 — one line.** `cw.compat` implements argh's eleven measured names: + +```diff +-import argh ++from cw import compat as argh +``` + +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"] +``` + +**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 +`__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' +# ... 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. + +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 / 137 cases ``` -### AST Parser +`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/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/__init__.py b/cw/__init__.py index 202354f..1390495 100644 --- a/cw/__init__.py +++ b/cw/__init__.py @@ -1,5 +1,145 @@ +"""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, 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 """ -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.cli import ( + BoundKeywordWarning, + add_commands, + dispatch, + enable_completion, + mk_parser, + run, + set_default_command, +) +from cw.commands import CommandTreeError, commands_from +from cw.convention import ( + ARGH, + BY_NAME_IF_HAS_DEFAULT, + BY_NAME_IF_KWONLY, + 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, +) +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_to_function, + resource_inputs, +) + +__all__ = [ + # -- base: the shared vocabulary --------------------------------------------------- + "ArghHelpFormatter", + "Codec", + "CommandError", + "Decode", + "Egress", + "HIDE", + "MISSING", + # -- cli: building a parser, and running one ---------------------------------------- + "BoundKeywordWarning", + "add_commands", + "dispatch", + "enable_completion", + "mk_parser", + "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", + "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", + "BY_NAME_IF_KWONLY", + "MODERN", + "Convention", + # -- resolution: string specification -> callable ----------------------------------- + "parse_ast_spec", + "parse_json_spec", + "parse_spec_with_dot_path", + "resolve_func_from_dot_path", + "resolve_to_function", + "resource_inputs", +] 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/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/cli.py b/cw/cli.py new file mode 100644 index 0000000..89af9fb --- /dev/null +++ b/cw/cli.py @@ -0,0 +1,836 @@ +"""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 os +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", + "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. +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 + +#: 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: + """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 + #: ``{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) + + +# -------------------------------------------------------------------------------------- +# 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) + 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: + 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 _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 + 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. 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) 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_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, + ) + + +def set_default_command( + parser: argparse.ArgumentParser, + func: Callable, + /, + *, + 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. + + 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. + + ``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, command=command) + for spec in specs: + if spec.param_name == RESERVED_DEST: + raise GrammarError( + 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: + 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( + convention=convention, + 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}) + + +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, command=name + ) + + +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)" + plural = len(unknown) > 1 + raise GrammarError( + 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." + ) + + +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=_child_formatter(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=_child_formatter(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=_child_formatter(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.""" + renames = stash.renames or {} + values = { + renames.get(name, 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..7c0e039 --- /dev/null +++ b/cw/commands.py @@ -0,0 +1,290 @@ +"""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 _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. + + 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__"): + 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." + ) + + +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): + _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 " + "supports one level of grouping and so does cw; flatten the inner group, " + "or give its commands longer names." + ) + else: + _put( + 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__}." + ) + _put(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/compat.py b/cw/compat.py new file mode 100644 index 0000000..086fc70 --- /dev/null +++ b/cw/compat.py @@ -0,0 +1,614 @@ +"""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, 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 +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']} + + 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): + # 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)), + **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' + + 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) + + 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.", + "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)`." + ), +} + + +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/convention.py b/cw/convention.py new file mode 100644 index 0000000..a6d1cb2 --- /dev/null +++ b/cw/convention.py @@ -0,0 +1,144 @@ +"""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) +>>> 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: + +>>> 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') + +``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, Egress +from cw.egress import argh_egress, iterable_egress +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 + #: 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. +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, +#: ``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/grammar.py b/cw/grammar.py new file mode 100644 index 0000000..86f82b6 --- /dev/null +++ b/cw/grammar.py @@ -0,0 +1,819 @@ +"""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 + #: 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: + """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 + if other.completer is not None: + self.completer = other.completer + 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. + + 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: + 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. + + 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( # 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) + """ + 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) + # 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: + 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') + (, ) + + 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): + # `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) + + +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): + ... ... + >>> 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 + 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/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/resolution.py b/cw/resolution.py index 2829cc7..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]) @@ -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/cw/testing.py b/cw/testing.py new file mode 100644 index 0000000..c95a7d7 --- /dev/null +++ b/cw/testing.py @@ -0,0 +1,1016 @@ +"""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'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") +------------------------------------ + +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 + +#: 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( + 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. + + >>> 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") + + +#: ``(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_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. + + ``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 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'] + """ + 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: + """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, *, 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)) + '' + >>> print(compare_case(a, dict(a, stdout='ho\\n'))) + 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}) + '' + """ + 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) + if field == "returncode": + if want != got: + chunks.append(f"returncode:\n - {want!r}\n + {got!r}") + continue + 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)) + 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( + (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, + strict_help=False, +) -> 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. + + 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``, ``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"]) + 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, strict_help=strict_help)) + return results + + +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, strict_help=strict_help) + if tuple(argv) in intended: + 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: + """: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=[]) + 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") + 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, + strict_help=args.strict_help, + ) + bad = [r for r in results if r["status"] in ("differs", "unexpected-match")] + advisory = [r for r in results if r["status"] == "help-differs"] + for result in bad + advisory: + print(_report_line(result)) + 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 + + +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..6101df2 --- /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 / 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 +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` | 13 | +| `contract` | the D2 contract's egress and error rows | 17 | +| **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 137 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..f6f5e47 --- /dev/null +++ b/cw/tests/fixtures.py @@ -0,0 +1,1220 @@ +"""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 +from typing import Literal + +__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"] + + +# `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", + 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. `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, convert_tree], + cw_kwargs={"description": "Annotation store tooling."}, + argh_build=lambda argh, argparse: _subcommands( + argh, + argparse, + [migrate, convert, list_formats, convert_tree], + 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"], + # 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"], + ], +) + + +# ======================================================================================= +# 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..6968159 --- /dev/null +++ b/cw/tests/goldens/lacing.json @@ -0,0 +1,175 @@ +{ + "cases": [ + { + "argv": [], + "returncode": 0, + "stderr": "", + "stdout": "usage: lacing [-h] {migrate,convert,list-formats,convert-tree} ...\n", + "tier": 1, + "usage": "usage: lacing [-h] {migrate,convert,list-formats,convert-tree} ..." + }, + { + "argv": [ + "--help" + ], + "returncode": 0, + "stderr": "", + "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,convert-tree} ..." + }, + { + "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" + }, + { + "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, + "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. `convert-tree` adds the hyphenated-positional-with-choices shape that both corpora were blind to.", + "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/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/docs/adr/0001-the-v1-seam-table.md b/docs/adr/0001-the-v1-seam-table.md new file mode 100644 index 0000000..bcdf0eb --- /dev/null +++ b/docs/adr/0001-the-v1-seam-table.md @@ -0,0 +1,179 @@ +# 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 — 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 | +|---|---|---|---| +| 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 / 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. +- `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..98956f3 --- /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 / 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. + +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 / 137 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 / 137 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/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 new file mode 100644 index 0000000..08ee919 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,18 @@ +# Architecture decision records + +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). + +| 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) | +| [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/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 37725f8..e929c1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,84 @@ [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 = [] -dependencies = [ - "i2", +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 = [] +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.license] -text = "MIT" - [project.urls] Homepage = "https://github.com/i2mint/cw" +Issues = "https://github.com/i2mint/cw/issues" [project.optional-dependencies] -dev = [ +resource = [ + "i2", +] +completion = [ + "argcomplete>=3", +] +# 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. +# +# `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", +] +# 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", + "argh==0.31.3", ] docs = [ "sphinx>=6.0", @@ -76,7 +127,9 @@ convention = "google" minversion = "6.0" testpaths = [ "tests", + "cw", ] +addopts = "--doctest-modules" doctest_optionflags = [ "NORMALIZE_WHITESPACE", "ELLIPSIS", @@ -85,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/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b43c7e6 --- /dev/null +++ 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 new file mode 100644 index 0000000..9023ac2 --- /dev/null +++ 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/conftest.py b/tests/argh_parity/conftest.py new file mode 100644 index 0000000..21f7b85 --- /dev/null +++ b/tests/argh_parity/conftest.py @@ -0,0 +1,17 @@ +"""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]` +(`[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 +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/argh_parity/corpus.py b/tests/argh_parity/corpus.py new file mode 100644 index 0000000..566ed5a --- /dev/null +++ b/tests/argh_parity/corpus.py @@ -0,0 +1,321 @@ +"""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 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="", + 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("hyphenated_positional_with_literal", hyphenated_positional_with_literal), + Case( + "hyphenated_positional_with_declared_choices", + hyphenated_positional_with_declared_choices, + ), + 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..2b7bc09 --- /dev/null +++ b/tests/argh_parity/harness.py @@ -0,0 +1,164 @@ +"""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. + +**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 +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 cw's one former divergence used to hide. +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. Nothing is collapsed; see the module docstring.""" + 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, + } + 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_cli_parity.py b/tests/argh_parity/test_cli_parity.py new file mode 100644 index 0000000..432c861 --- /dev/null +++ b/tests/argh_parity/test_cli_parity.py @@ -0,0 +1,404 @@ +"""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 os + +import argh +import pytest + +import cw + +from tests.capture import capture + +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 + + +#: Defined in `tests/capture.py` so that importing it does not drag in argh. +_capture = capture + + +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(" len(ALL_CASES) - 5, f"only {built} of {len(ALL_CASES)} cases built" 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_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_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c823883 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,666 @@ +"""`cw.cli`: the parser cw builds, the stash it travels in, and the run that uses it. + +Two constraints dominate this module and both are asserted here rather than described: + +* `mk_parser` returns a **plain** `argparse.ArgumentParser` and performs no I/O, because + `argcomplete.autocomplete` is argparse-typed and `mk_parser` is what a test inspects; +* everything `run` needs about a subcommand travels in **one reserved namespace key**, + since a plain parser has nowhere else to put it -- and a collision with that key is an + error naming it, never silent corruption. +""" + +import argparse +import contextlib +import dataclasses +import functools +import io +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 ArgSpec, GrammarError + + +def echo(word, *, loud=False): + """Echo a word.""" + return word.upper() if loud else word + + +def counted(): + """Return a map object -- the shape where ARGH and MODERN differ.""" + return map(str, range(2)) + + +def _leaf_help(parser, *path): + """The `--help` of a subcommand, reached by walking the subparser actions.""" + for name in path: + subparsers = next( + a for a in parser._actions if isinstance(a, argparse._SubParsersAction) + ) + parser = subparsers.choices[name] + return parser.format_help() + + +def capture(obj, argv, **kwargs): + """`(exit_code, stdout, stderr)` from one dispatch.""" + out, err = io.StringIO(), io.StringIO() + code = cw.dispatch(obj, argv, out=out, err=err, prog="x", **kwargs) + return code, out.getvalue(), err.getvalue() + + +class TestMkParserIsPlainAndPure: + def test_returns_the_argparse_class_itself(self): + """Not a subclass: `argcomplete.autocomplete(parser: ArgumentParser)` is typed.""" + assert type(cw.mk_parser(echo)) is argparse.ArgumentParser + + def test_subparsers_are_plain_too(self): + parser = cw.mk_parser({"echo": echo, "grp": {"echo": echo}}) + subparsers = next( + a for a in parser._actions if isinstance(a, argparse._SubParsersAction) + ) + assert type(subparsers.choices["echo"]) is argparse.ArgumentParser + assert type(subparsers.choices["grp"]) is argparse.ArgumentParser + + def test_performs_no_io(self): + """Completion reads the environment and can exit the process; not at build time.""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + cw.mk_parser({"echo": echo, "grp": {"echo": echo}}, prog="x") + assert (out.getvalue(), err.getvalue()) == ("", "") + + def test_does_not_fire_completion(self, monkeypatch): + fired = [] + monkeypatch.setattr( + cw.cli, "enable_completion", lambda *a, **k: fired.append(1) + ) + cw.mk_parser(echo) + assert fired == [] + cw.dispatch(echo, ["hi"], out=io.StringIO()) + assert fired == [1], "completion fires at dispatch time, as it does in argh" + + def test_parser_kwargs_pass_through_verbatim(self): + parser = cw.mk_parser( + echo, prog="x", description="D", epilog="E", allow_abbrev=False + ) + assert (parser.prog, parser.description, parser.epilog) == ("x", "D", "E") + assert parser.allow_abbrev is False + + def test_the_default_formatter_is_cws_and_reaches_subparsers(self): + parser = cw.mk_parser({"echo": echo}) + assert parser.formatter_class is cw.ArghHelpFormatter + subparsers = next( + a for a in parser._actions if isinstance(a, argparse._SubParsersAction) + ) + assert subparsers.choices["echo"].formatter_class is cw.ArghHelpFormatter + + def test_a_given_formatter_reaches_subparsers_too(self): + parser = cw.mk_parser( + {"echo": echo, "grp": {"echo": echo}}, + formatter_class=argparse.RawTextHelpFormatter, + ) + subparsers = next( + a for a in parser._actions if isinstance(a, argparse._SubParsersAction) + ) + assert ( + subparsers.choices["echo"].formatter_class is argparse.RawTextHelpFormatter + ) + assert ( + subparsers.choices["grp"].formatter_class is argparse.RawTextHelpFormatter + ) + + def test_an_unknown_parser_kwarg_names_cw_and_the_real_ones(self): + with pytest.raises(TypeError) as caught: + cw.mk_parser(echo, prgo="x") + message = str(caught.value) + assert "cw" in message and "argparse.ArgumentParser accepts" in message + + +class TestTheStash: + def test_the_reserved_key_carries_the_command(self): + parser = cw.mk_parser(echo) + stash = parser.get_default(RESERVED_DEST) + assert stash.func is echo and callable(stash.ingress) + + def test_a_parameter_of_the_reserved_name_is_an_informative_error(self): + def clash(_cw=1): ... + + with pytest.raises(GrammarError, match=re.escape(RESERVED_DEST)): + cw.mk_parser(clash) + + def test_the_reserved_key_never_reaches_the_function(self): + seen = {} + + def catch_all(**rest): + seen.update(rest) + + cw.dispatch(catch_all, [], standalone=False) + assert seen == {} + + def test_the_convention_travels_with_the_parser(self): + """`mk_parser(convention=MODERN)` + `run` must not silently get ARGH's egress.""" + out = io.StringIO() + cw.run(cw.mk_parser(counted, convention=cw.MODERN), [], out=out) + assert out.getvalue() == "0\n1\n" + out = io.StringIO() + cw.run(cw.mk_parser(counted), [], out=out) + assert out.getvalue().startswith("= 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 new file mode 100644 index 0000000..6f9e31b --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,509 @@ +"""`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"}) + + +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 new file mode 100644 index 0000000..77b2658 --- /dev/null +++ b/tests/test_convention.py @@ -0,0 +1,216 @@ +"""`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 +change -- a negative control, not a description. +""" + +import dataclasses +import enum +import io +import pathlib + +import pytest + +import cw + + +def echo(word, *, loud=False): + """Echo.""" + return word + + +def helper_command(retries=3, region="eu"): + """Two parameters, one shared first letter is not among them.""" + return retries + + +class TestTheTwoValues: + def test_argh_is_the_bare_default(self): + assert cw.ARGH == cw.Convention() + + def test_both_are_frozen_and_hashable(self): + assert hash(cw.ARGH) == hash(cw.Convention()) + assert {cw.ARGH, cw.MODERN} + with pytest.raises(dataclasses.FrozenInstanceError): + cw.ARGH.short_flags = False + + def test_replace_round_trips(self): + half = dataclasses.replace(cw.ARGH, resolve_hints=True) + assert half.resolve_hints is True and half.naming == cw.ARGH.naming + assert dataclasses.replace(half, resolve_hints=False) == cw.ARGH + + def test_argh_carries_arghs_seams_and_modern_carries_moderns(self): + assert (cw.ARGH.decode, cw.ARGH.egress) == (cw.argh_decode, cw.argh_egress) + assert (cw.MODERN.decode, cw.MODERN.egress) == ( + cw.modern_decode, + cw.iterable_egress, + ) + + def test_modern_keeps_the_default_in_the_help_column(self): + """ADR-0004 rule 4: with no docstring-derived help in v1, turning this off would + leave an undocumented flag with an empty help column -- worse than argh, in the + value the fleet is being told to adopt.""" + assert cw.MODERN.default_in_help is True + assert "3" in cw.mk_parser(helper_command, convention=cw.MODERN).format_help() + + def test_formatter_class_is_not_a_field(self): + """It is already an `ArgumentParser` keyword; two homes would need a precedence.""" + assert "formatter_class" not in { + f.name for f in dataclasses.fields(cw.Convention) + } + + +class TestEveryFieldChangesSomething: + """One negative control per field. A field that survives its own flip is dead code.""" + + def test_naming(self): + def f(path="."): ... + + by_kwonly = dataclasses.replace(cw.ARGH, naming=cw.BY_NAME_IF_KWONLY) + assert cw.mk_parser(f, prog="x").format_usage() == "usage: x [-h] [-p PATH]\n" + assert ( + cw.mk_parser(f, prog="x", convention=by_kwonly).format_usage() + == "usage: x [-h] [path]\n" + ) + + def test_short_flags(self): + off = dataclasses.replace(cw.ARGH, short_flags=False) + assert "-l, --loud" in cw.mk_parser(echo).format_help() + assert "-l, --loud" not in cw.mk_parser(echo, convention=off).format_help() + + def test_hyphenate_commands(self): + def two_words(): ... + + assert "two-words" in cw.mk_parser([two_words]).format_help() + off = dataclasses.replace(cw.ARGH, hyphenate_commands=False) + assert "two_words" in cw.mk_parser([two_words], convention=off).format_help() + + def test_hyphenate_groups(self): + assert "git_ops" in cw.mk_parser({"git_ops": [echo]}).format_help() + on = dataclasses.replace(cw.ARGH, hyphenate_groups=True) + assert ( + "git-ops" in cw.mk_parser({"git_ops": [echo]}, convention=on).format_help() + ) + + def test_default_in_help(self): + assert "3" in cw.mk_parser(helper_command).format_help() + off = dataclasses.replace(cw.ARGH, default_in_help=False) + assert "3" not in cw.mk_parser(helper_command, convention=off).format_help() + + def test_hints_when_declared(self): + """argh switches hint inference off for the *whole function* on one override.""" + + def add(*, a: int = None, b: int = None): + return a + b + + config = {"a": {"help": "the first"}} + assert ( + cw.dispatch(add, ["-a", "2", "-b", "3"], config=config, standalone=False) + == "23" + ) # both stayed strings + assert ( + cw.dispatch( + add, + ["-a", "2", "-b", "3"], + config=config, + convention=cw.MODERN, + standalone=False, + ) + == 5 + ) + + def test_resolve_hints(self): + """PEP 563 blindness is argh's, and it is a named switch rather than a bug fix.""" + + def migrate(*, to_version: "int" = None): + return to_version + + assert cw.dispatch(migrate, ["--to-version", "4"], standalone=False) == "4" + assert ( + cw.dispatch( + migrate, ["--to-version", "4"], convention=cw.MODERN, standalone=False + ) + == 4 + ) + + def test_decode(self): + def load(*, path: pathlib.Path = None): + return path + + assert cw.dispatch(load, ["--path", "/tmp"], standalone=False) == "/tmp" + assert cw.dispatch( + load, ["--path", "/tmp"], convention=cw.MODERN, standalone=False + ) == pathlib.Path("/tmp") + + def test_egress(self): + def counted(): + return map(str, range(2)) + + out = io.StringIO() + cw.dispatch(counted, [], out=out) + assert out.getvalue().startswith(" 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 + + # `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")) + 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_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_egress.py b/tests/test_egress.py new file mode 100644 index 0000000..6fe072e --- /dev/null +++ b/tests/test_egress.py @@ -0,0 +1,251 @@ +"""`cw.egress`: what a return value becomes, and where it lands. + +The half of these tests that asks "is this what argh does" lives in +`tests/argh_parity/test_cli_parity.py`, where argh answers for itself. What is here is the +half argh cannot answer: cw's own additions, and the stream discipline that is the whole +reason a cw CLI is testable and an argh CLI is not. +""" + +import io +import sys + +import pytest + +import cw +from cw.egress import guard_no_coroutine, write_lines + + +class TestStreamsResolveAtCallTime: + """argh binds `output_file=sys.stdout` in a signature default; cw looks it up now. + + That one difference is why `capsys`, `redirect_stdout` and a plain `out=` all work. + """ + + def test_out_captures_everything(self): + buffer = io.StringIO() + assert cw.argh_egress(["a", "b"], out=buffer) == 0 + assert buffer.getvalue() == "a\nb\n" + + def test_capsys_captures_a_whole_dispatch(self, capsys): + cw.dispatch(lambda: ["one", "two"], []) + assert capsys.readouterr().out == "one\ntwo\n" + + def test_redirect_stdout_captures_a_whole_dispatch(self): + import contextlib + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + cw.dispatch(lambda: "hi", []) + assert buffer.getvalue() == "hi\n" + + def test_no_stream_is_bound_in_a_signature_default(self): + """The defect being avoided, asserted at the signature rather than in prose.""" + import inspect + + for func in (cw.argh_egress, cw.iterable_egress, cw.json_egress, cw.confirm): + for parameter in inspect.signature(func).parameters.values(): + assert parameter.default is not sys.stdout, func + assert parameter.default is not sys.stderr, func + + +class TestWriteLines: + def test_writes_str_of_each_item(self): + buffer = io.StringIO() + write_lines([1, None, "x"], out=buffer) + assert buffer.getvalue() == "1\nNone\nx\n" + + def test_is_lazy(self): + """Row 18: a generator's output must land before the command's next prompt.""" + buffer = io.StringIO() + seen = [] + + def lines(): + yield "first" + seen.append(buffer.getvalue()) + yield "second" + + write_lines(lines(), out=buffer) + assert seen == ["first\n"] + + def test_flushes_each_line(self): + class Counting(io.StringIO): + flushes = 0 + + def flush(self): + type(self).flushes += 1 + + buffer = Counting() + write_lines(["a", "b"], out=buffer) + assert Counting.flushes == 2 + + def test_flush_can_be_turned_off(self): + class Counting(io.StringIO): + flushes = 0 + + def flush(self): + type(self).flushes += 1 + + write_lines(["a", "b"], out=Counting(), flush=False) + assert Counting.flushes == 0 + + +class TestWhichResultsAreLines: + """The single difference between `argh_egress` and `iterable_egress`.""" + + @pytest.mark.parametrize( + "result, argh_lines, modern_lines", + [ + ([1, 2], "1\n2\n", "1\n2\n"), + ((1, 2), "1\n2\n", "1\n2\n"), + ({"a": 1}, "{'a': 1}\n", "{'a': 1}\n"), + ("two words", "two words\n", "two words\n"), + (b"bytes", "b'bytes'\n", "b'bytes'\n"), + (None, "", ""), + (0, "0\n", "0\n"), + (False, "False\n", "False\n"), + ("", "\n", "\n"), + ], + ) + def test_agree_except_on_the_protocol(self, result, argh_lines, modern_lines): + for egress, expected in ( + (cw.argh_egress, argh_lines), + (cw.iterable_egress, modern_lines), + ): + buffer = io.StringIO() + egress(result, out=buffer) + assert buffer.getvalue() == expected, egress + + def test_a_set_is_one_line_for_argh_and_several_for_modern(self): + buffer = io.StringIO() + cw.argh_egress({7}, out=buffer) + assert buffer.getvalue() == "{7}\n" + buffer = io.StringIO() + cw.iterable_egress({7}, out=buffer) + assert buffer.getvalue() == "7\n" + + def test_a_generator_is_lines_for_both(self): + def lines(): + yield "a" + yield "b" + + for egress in (cw.argh_egress, cw.iterable_egress): + buffer = io.StringIO() + egress(lines(), out=buffer) + assert buffer.getvalue() == "a\nb\n", egress + + def test_a_plain_iterator_is_NOT_a_generator(self): + """The sharpest edge of the whitelist, verified against live argh. + + argh tests for `GeneratorType`, so `iter([...])` -- a `list_iterator` -- prints + its repr rather than its items. + """ + buffer = io.StringIO() + cw.argh_egress(iter(["a", "b"]), out=buffer) + assert buffer.getvalue().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..5c369fb --- /dev/null +++ b/tests/test_fleet_shapes.py @@ -0,0 +1,225 @@ +"""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 pytest + +import cw + +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 + + +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. + + 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): + 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}} + 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"], +] + + +@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. + + `--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. + """ + # 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( + 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_grammar.py b/tests/test_grammar.py new file mode 100644 index 0000000..4a0b756 --- /dev/null +++ b/tests/test_grammar.py @@ -0,0 +1,544 @@ +"""`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. + """ + # 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) + 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_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",), + {}, + ) + 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_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(): + 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 diff --git a/tests/test_import_is_cheap.py b/tests/test_import_is_cheap.py new file mode 100644 index 0000000..8acaef1 --- /dev/null +++ b/tests/test_import_is_cheap.py @@ -0,0 +1,60 @@ +"""``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 = {} + # 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.relative_to(package_dir)}:{lineno}"] = ( + line.strip() + ) + assert not offenders, f"module-scope third-party imports: {offenders}" 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..6b7f5e6 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,114 @@ +"""`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_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): + """`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 diff --git a/tests/test_resolution.py b/tests/test_resolution.py new file mode 100644 index 0000000..cd22dc5 --- /dev/null +++ b/tests/test_resolution.py @@ -0,0 +1,90 @@ +"""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_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.""" + 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)) diff --git a/tests/test_testing.py b/tests/test_testing.py new file mode 100644 index 0000000..2c3b8a3 --- /dev/null +++ b/tests/test_testing.py @@ -0,0 +1,720 @@ +"""`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" + ) + 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 + 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 + + +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"), + ) + != "" + ) + + +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", + ]