From 39c603e4b0ea95e49198d1b9d899d9497a61ec5a Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 22 Sep 2026 14:35:21 +0200 Subject: [PATCH 1/2] feat(cli,mcp): accept -h and -V/--version - osw: -h is an alias of --help on every command - osw and osw-mcp: -V/--version print one line and exit 0 - the line names the version, package location and Python version - neither flag needs configuration, credentials or network - osw-mcp rejects an unknown or abbreviated argument with exit 2 --- docs/tools/cli.md | 7 ++ docs/tools/mcp.md | 7 ++ src/osw/cli/main.py | 47 ++++++++++++- src/osw/mcp/server.py | 82 ++++++++++++++++++---- src/osw/service/version.py | 28 ++++++++ tests/test_cli.py | 140 ++++++++++++++++++++++++++++++++++--- tests/test_mcp_server.py | 124 ++++++++++++++++++++++++++++++-- tests/test_osw_entry.py | 17 +++++ 8 files changed, 426 insertions(+), 26 deletions(-) create mode 100644 src/osw/service/version.py diff --git a/docs/tools/cli.md b/docs/tools/cli.md index 54255620..c357787f 100644 --- a/docs/tools/cli.md +++ b/docs/tools/cli.md @@ -70,5 +70,12 @@ form. - `--verbose` / `-v` shows full tracebacks instead of a one-line message, and adds the env-file line to the source report described under [Where settings come from](configuration.md#where-settings-come-from). +- `--version` / `-V` prints one line with the osw version, the location of + the osw package and the Python version, then exits, needing no instance or + credentials. + +`--help` / `-h` works on `osw` itself and after any subcommand or group, e.g. +`osw entity --help` or `osw entity get -h`, and prints that command's own +help. Failures exit non-zero with a short message on stderr and no traceback. diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index c022c234..f933936c 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -19,6 +19,13 @@ instance for its whole lifetime; there is no tool to switch at runtime. `.env` file that entry names. Without it the server refuses to start rather than register tools that would all fail. +`osw-mcp` also answers two flags from a shell, for checking an install rather +than for an MCP client to pass: `-h` / `--help` prints usage and exits, and +`-V` / `--version` prints one line with the osw version, the location of the +osw package and the Python version, then exits. Neither one starts the server +or needs an instance or credentials. Any other argument, including a +shortened form of these two flags, is rejected with a usage error. + ## Quick install For Claude Code, one command registers the server. Replace the domain and the diff --git a/src/osw/cli/main.py b/src/osw/cli/main.py index 1195c6fb..7eee7a90 100644 --- a/src/osw/cli/main.py +++ b/src/osw/cli/main.py @@ -31,11 +31,23 @@ from osw.service.params import json_value from osw.service.registry import Operation, bind, iter_operations from osw.service.streams import force_utf8 +from osw.service.version import version_line from osw.wtsite import SLOTS from .render import render -app = typer.Typer(no_args_is_help=True, add_completion=False) +# help_option_names adds -h as an alias for --help. A child context created +# for a subcommand or a subgroup inherits it from its parent context when the +# child sets none of its own (click.Context.__init__), so this one setting +# covers the whole command tree, not just the root. --help comes first: click +# builds its "Try '... --help' for help." hint from help_option_names[0], and +# every existing usage error already names --help, so putting -h first would +# have changed all of them. +app = typer.Typer( + no_args_is_help=True, + add_completion=False, + context_settings={"help_option_names": ["--help", "-h"]}, +) def _force_utf8_output() -> None: @@ -53,14 +65,43 @@ def _force_utf8_output() -> None: stays exposed is the name the user typed, echoed back in a usage error -- an unknown command name or an unknown root option name. A name typed after the command is fine, because click resolves the command, runs this - callback, and only then parses the command's own arguments. + callback, and only then parses the command's own arguments. --version / + -V also prints before this callback runs (its own callback is eager, like + --help), but it names no wiki content either, and forces UTF-8 on stdout + itself (see ``_version_callback``) rather than relying on this function. """ force_utf8(sys.stdout, sys.stderr) +def _version_callback(value: bool) -> None: + """Eager callback for --version / -V: print the line and exit. + + click processes an eager option's callback before a command's own + callback body runs, so this needs no configuration, no credential file + and no network -- the same reason ``--help`` works with none of those. + It also runs before ``_force_utf8_output``, so it forces stdout to UTF-8 + itself: the line names the package's install directory, which a redirected + stdout on Windows would otherwise encode with the locale encoding, raising + ``UnicodeEncodeError`` on a path outside it. ``osw-mcp -V`` does the same + (``osw.mcp.server.main``). + """ + if value: + force_utf8(sys.stdout) + typer.echo(version_line("osw")) + raise typer.Exit() + + @app.callback() def _callback( ctx: typer.Context, + version: bool = typer.Option( + False, + "--version", + "-V", + help="Show the version and exit.", + is_eager=True, + callback=_version_callback, + ), instance: Optional[str] = typer.Option( None, "--instance", @@ -113,6 +154,8 @@ def _callback( "--read-only": "--read-only", "--verbose": "--verbose", "-v": "-v", + "--version": "--version", + "-V": "-V", } diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 9f863c61..f8f3812a 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -7,11 +7,12 @@ from __future__ import annotations +import argparse import atexit import inspect import io import sys -from typing import Any, Optional, TextIO +from typing import Any, Optional, Sequence, TextIO from mcp.server import MCPServer from mcp.types import ToolAnnotations @@ -23,6 +24,7 @@ from osw.service.context import Context, Policy from osw.service.registry import Operation, bind, iter_operations from osw.service.streams import force_utf8 +from osw.service.version import version_line INSTRUCTIONS = """\ This server is pinned to exactly one OpenSemanticLab (OSL) instance for its @@ -114,9 +116,11 @@ def _build_server(report: Optional[TextIO] = None) -> tuple[MCPServer, Context]: discarded. """ # Set before any shared-code logging runs, so every "[xxx] ..." message - # and wiki edit comment from shared code names this adapter. Also set as - # the first statement of main(), since main() prints on a start failure - # and this function is also callable on its own, e.g. from tests. + # and wiki edit comment from shared code names this adapter. main() sets + # it again before calling this function -- not as its first statement: + # force_utf8, argument parsing and the --version branch run before it + # there -- since main() prints on a start failure and this function is + # also callable on its own, e.g. from tests. config.set_log_prefix("osw-mcp") # Before get_settings(), so a misconfiguration that makes loading raise # still reports which files were read. Into `report` rather than stderr: @@ -155,20 +159,74 @@ def create_server() -> MCPServer: return mcp -def main() -> None: - """Console-script entry point: build the server and serve over stdio.""" - # Before any write below. An MCP client starts this server with stderr on - # a pipe, so Python encodes it with the locale encoding, cp1252 on a - # German Windows system. The report holds the credential file path and the - # env file path, so a directory named "Muller" with an umlaut is enough to - # reach the client's log mangled. Reconfiguring in place also covers osw's - # own log handler, which holds this same stream object. +# Wrapped by hand, below 80 columns, with the URL on a line of its own. +# argparse's default formatter re-wraps a description with textwrap +# (break_on_hyphens=True), so at some terminal widths a line ends with +# ".../osw-" and the next starts with "python/blob/...", splitting the URL. +# RawDescriptionHelpFormatter (below) prints this text verbatim instead. +_DESCRIPTION = ( + "Runs an MCP server for one OpenSemanticLab instance over stdio. An MCP\n" + "client starts it, not a person from a shell. Configuration comes from\n" + "environment variables, a .env file, or a credential file; see\n" + "https://github.com/OpenSemanticLab/osw-python/blob/main/docs/tools/mcp.md." +) + + +def _build_parser() -> argparse.ArgumentParser: + """Build the argument parser for the ``osw-mcp`` console script. + + Parsing happens before :func:`_build_server`, so ``-h``, ``-V`` and an + unrecognized argument never need credentials. + """ + parser = argparse.ArgumentParser( + prog="osw-mcp", + description=_DESCRIPTION, + formatter_class=argparse.RawDescriptionHelpFormatter, + # argparse accepts an unambiguous abbreviation by default (e.g. + # "--vers"); osw's own CLI (click) does not, so this is turned off to + # match: an argument that is not exactly one of the two below is + # rejected. + allow_abbrev=False, + ) + parser.add_argument( + "-V", + "--version", + action="store_true", + help="show the version and exit", + ) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> None: + """Console-script entry point: build the server and serve over stdio. + + ``argv`` follows ``sys.argv[1:]``'s convention: ``None`` (the default) + reads the real command line, and a caller that wants to pass its own, + such as a test or ``python -m osw.mcp``, gives an explicit list instead. + """ + # Before any write below, including the usage error argparse prints for + # an unrecognized argument, which echoes it back. An MCP client starts + # this server with stderr on a pipe, so Python encodes it with the locale + # encoding, cp1252 on a German Windows system. The report holds the + # credential file path and the env file path, so a directory named + # "Muller" with an umlaut is enough to reach the client's log mangled. + # Reconfiguring in place also covers osw's own log handler, which holds + # this same stream object. # # stdout is deliberately left alone. The SDK's stdio_server re-wraps the # binary buffer as UTF-8 itself, and claims file descriptor 1 while doing # it, so the JSON-RPC channel does not depend on this and changing it here # would only add a way to interfere. force_utf8(sys.stderr) + args = _build_parser().parse_args(argv) + + if args.version: + # No server runs on this path, so the reason above for leaving + # stdout alone does not apply. + force_utf8(sys.stdout) + print(version_line("osw-mcp")) + return + # See _build_server for why this is set here too. config.set_log_prefix("osw-mcp") report = io.StringIO() diff --git a/src/osw/service/version.py b/src/osw/service/version.py new file mode 100644 index 00000000..c1d74eed --- /dev/null +++ b/src/osw/service/version.py @@ -0,0 +1,28 @@ +"""Version banner shared by the osw CLI and the osw-mcp server. + +Neither adapter may import the other (see :mod:`osw.service.streams`), so the +one line both ``--version`` options print lives here instead of in either of +them. See :func:`version_line`. +""" + +from __future__ import annotations + +import os +import platform + +import osw + + +def version_line(prog: str) -> str: + """The one-line version banner ``prog`` prints for --version / -V. + + ``prog`` names the console script asking (``"osw"`` or ``"osw-mcp"``), so + the same installed package reports under whichever command the caller + actually ran. The remaining fields are the installed osw version, the + directory the osw package was loaded from, and the running Python + version, in that order. + """ + location = os.path.dirname(osw.__file__) + return ( + f"{prog} {osw.__version__} from {location} (Python {platform.python_version()})" + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 77fad5bb..b4ba14ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,8 @@ import io import json import logging +import os +import platform import re import sys from unittest.mock import MagicMock @@ -20,6 +22,7 @@ import yaml from typer.testing import CliRunner +import osw import osw.cli.main as cli_main from osw.cli.main import app from osw.cli.render import render @@ -133,9 +136,101 @@ def test_help_works_with_no_config_present(runner, args): assert result.exit_code == 0, result.stderr +# -- -h as an alias of --help (Change: issue #199) ----------------------------- +@pytest.mark.parametrize( + "args", + [["-h"], ["entity", "-h"], ["entity", "get", "-h"]], +) +def test_short_help_flag_works_with_no_config_present(runner, args): + result = runner.invoke(app, args) + assert result.exit_code == 0, result.stderr + + +@pytest.mark.parametrize( + "args", + [[], ["entity"], ["entity", "get"]], +) +def test_short_help_flag_prints_the_same_help_as_the_long_form(runner, args): + """-h has to work on the root, on a subgroup and on a leaf command, + each printing that same command's own help.""" + long_result = runner.invoke(app, [*args, "--help"]) + short_result = runner.invoke(app, [*args, "-h"]) + + assert short_result.exit_code == 0, short_result.stderr + assert _usage_error(short_result) == _usage_error(long_result) + + +# -- --version / -V (Change: issue #199) ---------------------------------------- +def _expected_version_line(prog: str) -> str: + """The line ``prog --version`` must print, built independently of + ``cli_main.version_line`` so a bug in that function (e.g. ignoring + ``prog``) cannot pass these tests by comparing itself to itself.""" + location = os.path.dirname(osw.__file__) + return ( + f"{prog} {osw.__version__} from {location} (Python {platform.python_version()})" + ) + + +@pytest.fixture +def _no_osw_env(monkeypatch, tmp_path): + """No OSW_* variable at all, not even one _ALL_VARS/``_clean_env`` does + not happen to list, and no .env file discoverable by searching upward + from the working directory. --version must work with neither, unlike + every other command, which is what this isolates for.""" + for key in list(os.environ): + if key.startswith("OSW_"): + monkeypatch.delenv(key, raising=False) + monkeypatch.chdir(tmp_path) + + +def test_version_flag_prints_one_line_matching_the_format(runner, _no_osw_env): + result = runner.invoke(app, ["--version"]) + + assert result.exit_code == 0, result.stderr + lines = result.stdout.splitlines() + assert len(lines) == 1 + assert lines[0].startswith("osw ") + assert lines[0] == _expected_version_line("osw") + + +def test_short_version_flag_prints_the_same_line(runner, _no_osw_env): + result = runner.invoke(app, ["-V"]) + + assert result.exit_code == 0, result.stderr + assert result.stdout.splitlines() == [_expected_version_line("osw")] + + +def test_version_callback_forces_stdout_to_utf8_before_printing(monkeypatch): + """The eager --version callback runs before ``_callback``'s own body, so + ``_force_utf8_output`` never covers it (see that function's docstring). + The line it prints names the package's install directory, which can + contain a character the locale encoding lacks, so it has to force UTF-8 + itself -- the same reason ``osw-mcp -V`` already does + (``osw.mcp.server.main``). + """ + out = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", out) + line = "osw 1.0 from C:\\Users\\Ren\u0151 (Python 3.12.7)" # \u0151 has no cp1252 code point + monkeypatch.setattr(cli_main, "version_line", lambda prog: line) + + with pytest.raises(typer.Exit): + cli_main._version_callback(True) + + out.flush() + # echo() appends "\n", which an io.TextIOWrapper with the default + # newline=None translates to os.linesep on write (e.g. "\r\n" on Windows). + assert out.buffer.getvalue().decode("utf-8").rstrip("\r\n") == line + + # -- lazy Context ------------------------------------------------------------- -def test_context_is_not_built_at_import_or_help_time(monkeypatch, runner): - """Building the app / answering --help must never construct a Context.""" +@pytest.mark.parametrize( + "cli_args", + [["entity", "get", "--help"], ["--version"]], + ids=["--help", "--version"], +) +def test_context_is_not_built_at_import_or_help_time(monkeypatch, runner, cli_args): + """Building the app / answering --help / --version must never construct + a Context.""" calls = [] orig_init = cli_main.Context.__init__ @@ -145,7 +240,7 @@ def spy_init(self, *args, **kwargs): monkeypatch.setattr(cli_main.Context, "__init__", spy_init) - result = runner.invoke(app, ["entity", "get", "--help"]) + result = runner.invoke(app, cli_args) assert result.exit_code == 0 assert calls == [] @@ -375,10 +470,13 @@ def test_every_help_string_is_ascii(): """Guards the one gap ``_force_utf8_output`` cannot close. Click prints help and rejects an unknown name before any callback runs, - so those paths keep the locale encoding. That is only harmless while no - help string contains a character the locale encoding may lack. Adding a - German option description would make it a real defect, and this test is - what reports it. + so those paths keep the locale encoding. --version / -V also prints + before that callback (its own callback is eager, like --help), but it + forces UTF-8 on stdout itself (see ``cli_main._version_callback``), so it + is not part of this gap and is exempt here. For everything else, this is + only harmless while no help string contains a character the locale + encoding may lack. Adding a German option description would make it a + real defect, and this test is what reports it. """ offenders = [] @@ -833,6 +931,32 @@ def test_root_option_after_grouped_command_names_the_correct_form(runner): assert "before the command" in combined +@pytest.mark.parametrize("flag", ["--version", "-V"]) +def test_version_option_after_grouped_command_names_the_correct_form(runner, flag): + """--version and -V (Change: issue #199) belong in _ROOT_OPTIONS too.""" + result = runner.invoke(app, ["entity", flag]) + + assert result.exit_code != 0 + combined = _usage_error(result) + assert flag in combined + assert "before the command" in combined + + +def test_unknown_subcommand_option_names_help_with_the_configured_order(runner): + """help_option_names is ``["--help", "-h"]`` (Change: issue #199), and + click builds its "Try '...' for help." hint from ``help_option_names[0]``, + so an ordinary usage error -- one _root_option_hint leaves untouched, + unlike --instance/--version above -- must still name --help, not -h. + CliRunner.invoke uses "root" as the program name in this hint, not "osw", + so the assertion checks the part after it rather than the whole line.""" + result = runner.invoke(app, ["entity", "--no-such-option"]) + + assert result.exit_code != 0 + combined = _usage_error(result) + assert "entity --help' for help." in combined + assert "entity -h' for help." not in combined + + def test_root_options_mapping_covers_every_root_option(): """_ROOT_OPTIONS is maintained by hand, next to but apart from _callback. @@ -846,7 +970,7 @@ def test_root_options_mapping_covers_every_root_option(): for param in root.params if isinstance(param, click.Option) for opt in [*param.opts, *param.secondary_opts] - if opt != "--help" + if opt not in ("--help", "-h") } assert declared == set(cli_main._ROOT_OPTIONS) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index aaf97f7f..72933b60 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -12,6 +12,8 @@ import asyncio import io import logging +import os +import platform import sys from contextlib import contextmanager @@ -207,11 +209,125 @@ def _serve_without_blocking(monkeypatch) -> None: monkeypatch.setattr(server.atexit, "register", lambda func: func) +# -- -h / -V / an unknown argument, all resolved before any credential is +# needed (Change: main() now takes argv and parses it with argparse) -------- +def _refuse_to_build(*args, **kwargs): + raise AssertionError("_build_server must not be called on this path") + + +def _expected_version_line(prog: str) -> str: + """The line ``prog --version`` must print, built independently of + ``server.version_line`` so a bug in that function (e.g. ignoring + ``prog``) cannot pass these tests by comparing itself to itself.""" + location = os.path.dirname(osw.__file__) + return ( + f"{prog} {osw.__version__} from {location} (Python {platform.python_version()})" + ) + + +def test_main_version_flag_prints_the_line_and_builds_no_server(monkeypatch, capsys): + monkeypatch.setattr(server, "_build_server", _refuse_to_build) + + server.main(["--version"]) + + out = capsys.readouterr().out.strip() + assert out.startswith("osw-mcp ") + assert out == _expected_version_line("osw-mcp") + + +def test_main_short_version_flag_prints_the_line_and_builds_no_server( + monkeypatch, capsys +): + monkeypatch.setattr(server, "_build_server", _refuse_to_build) + + server.main(["-V"]) + + assert capsys.readouterr().out.strip() == _expected_version_line("osw-mcp") + + +def test_main_help_flag_exits_zero_with_the_help_on_stdout(monkeypatch, capsys): + monkeypatch.setattr(server, "_build_server", _refuse_to_build) + + with pytest.raises(SystemExit) as exc_info: + server.main(["-h"]) + + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "osw-mcp" in out + assert "--version" in out + + +def test_main_with_no_argument_reads_sys_argv(monkeypatch, capsys): + """``argv=None`` (the default) is argparse's own convention for "read + the real command line", which ``ArgumentParser.parse_args`` implements by + reading ``sys.argv[1:]`` itself, at call time. This proves that path + actually works for ``main()``, not just that it forwards ``None``.""" + monkeypatch.setattr(sys, "argv", ["osw-mcp", "--version"]) + monkeypatch.setattr(server, "_build_server", _refuse_to_build) + + server.main() + + assert capsys.readouterr().out.strip() == _expected_version_line("osw-mcp") + + +_MCP_DOCS_URL = ( + "https://github.com/OpenSemanticLab/osw-python/blob/main/docs/tools/mcp.md." +) + + +@pytest.mark.parametrize("columns", ["60", "90", "132"]) +def test_help_description_keeps_the_url_on_one_line(monkeypatch, columns): + """argparse's default formatter wraps the description with textwrap, + break_on_hyphens=True, so at some terminal widths (90 is one) a line ends + with ".../osw-" and the next starts with "python/blob/...". The parser + has to keep the URL intact regardless of the terminal width; COLUMNS is + what shutil.get_terminal_size (which argparse's formatter uses) reads. + """ + monkeypatch.setenv("COLUMNS", columns) + + lines = server._build_parser().format_help().splitlines() + + assert any(_MCP_DOCS_URL in line for line in lines) + + +def test_argparse_help_text_is_ascii(): + """osw-mcp's argparse help goes to stdout on the -h path, which main() + does not force to UTF-8 (unlike the --version path). A non-ASCII + character in the description or an option's help would risk + UnicodeEncodeError on a locale that cannot represent it, the same class + of defect tests/test_cli.py::test_every_help_string_is_ascii guards + against for the typer app. + """ + assert server._build_parser().format_help().isascii() + + +def test_main_rejects_an_unknown_argument(monkeypatch, capsys): + monkeypatch.setattr(server, "_build_server", _refuse_to_build) + + with pytest.raises(SystemExit) as exc_info: + server.main(["--bogus"]) + + assert exc_info.value.code == 2 + assert "osw-mcp" in capsys.readouterr().err + + +def test_main_rejects_an_abbreviated_version_flag(monkeypatch, capsys): + """argparse accepts an unambiguous abbreviation by default; osw's own + CLI (click) does not, so osw-mcp turns that off (allow_abbrev=False) to + match: an argument that is not exactly --version or -V is rejected.""" + monkeypatch.setattr(server, "_build_server", _refuse_to_build) + + with pytest.raises(SystemExit) as exc_info: + server.main(["--vers"]) + + assert exc_info.value.code == 2 + + def test_main_is_quiet_on_a_successful_start(monkeypatch, capsys): _configure(monkeypatch) _serve_without_blocking(monkeypatch) - server.main() + server.main([]) assert "[osw]" not in capsys.readouterr().err @@ -222,7 +338,7 @@ def test_main_prints_the_report_when_osw_verbose_is_set(monkeypatch, capsys): config.reset() _serve_without_blocking(monkeypatch) - server.main() + server.main([]) err = capsys.readouterr().err assert "[osw-mcp] credentials" in err @@ -237,7 +353,7 @@ def test_main_prints_the_report_when_startup_fails(monkeypatch, tmp_path, capsys config.reset() with pytest.raises(SystemExit): - server.main() + server.main([]) err = capsys.readouterr().err assert "[osw-mcp] " in err @@ -262,7 +378,7 @@ def test_main_forces_utf8_on_stderr_and_leaves_stdout_alone(monkeypatch): monkeypatch.setattr(sys, "stdout", out) monkeypatch.setattr(sys, "stderr", err) - server.main() + server.main([]) assert err.encoding == "utf-8" # reconfigure() resets errors to strict unless it is passed as well, and a diff --git a/tests/test_osw_entry.py b/tests/test_osw_entry.py index 0fcc69e0..d65bb1c1 100644 --- a/tests/test_osw_entry.py +++ b/tests/test_osw_entry.py @@ -60,6 +60,23 @@ def test_the_osw_console_script_does_not_print_the_import_notice_on_stderr(): assert not any(NOTICE in line for line in result.stderr.splitlines()) +def test_the_osw_console_script_version_flag_prints_only_the_version_line(): + """--version must not carry the import notice either, and nothing else + on stdout (Change: issue #199).""" + result = subprocess.run( + [_console_script("osw"), "--version"], + capture_output=True, + **_DECODE, + env=_env_without_log_level(), + ) + + assert result.returncode == 0, result.stderr + assert not any(NOTICE in line for line in result.stderr.splitlines()) + lines = result.stdout.strip().splitlines() + assert len(lines) == 1 + assert lines[0].startswith("osw ") + + def test_importing_osw_directly_still_prints_the_notice_on_stderr(): """Without the shim, library behaviour is unchanged: the notice is still written, proving the console script above is quiet because of osw_entry From 8a65754c04565e8009ac7f0ff29713ac9b937ac0 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 22 Sep 2026 15:03:50 +0200 Subject: [PATCH 2/2] test(cli): check -h on every command, not three sample paths - the commands are generated from the operation registry - walk the whole click tree and require exit 0 for each --- tests/test_cli.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index b4ba14ae..c7d8168b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -160,6 +160,28 @@ def test_short_help_flag_prints_the_same_help_as_the_long_form(runner, args): assert _usage_error(short_result) == _usage_error(long_result) +def _command_paths(command: click.Command, path: list[str]) -> list[list[str]]: + """Every command and group below ``command``, as argument lists.""" + paths = [path] + if isinstance(command, click.Group): + for name, sub in command.commands.items(): + paths.extend(_command_paths(sub, [*path, name])) + return paths + + +def test_short_help_flag_works_on_every_command(runner): + """The commands are generated from the operation registry, so the three + paths above do not show that none of them sets its own context settings. + Walk the whole tree instead.""" + paths = _command_paths(typer.main.get_command(app), []) + failing = [ + path for path in paths if runner.invoke(app, [*path, "-h"]).exit_code != 0 + ] + + assert len(paths) > 3 + assert failing == [] + + # -- --version / -V (Change: issue #199) ---------------------------------------- def _expected_version_line(prog: str) -> str: """The line ``prog --version`` must print, built independently of