From c9d6f8c7a515212704b6388a46388e3cd902ea41 Mon Sep 17 00:00:00 2001 From: "a.krantz" Date: Tue, 30 Jun 2026 09:56:42 +0200 Subject: [PATCH] feat: fix command line help and add diff subcommand --- README.md | 22 ++++++++---- docs/usage.md | 22 +++++++----- src/odsbox_diff/diff.py | 54 +++++++++++++++++++++++------ tests/test_diff.py | 76 +++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 150 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index bb1debd..5069009 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,16 @@ pip install odsbox-diff The package requires Python 3.14+ and ships a console script `odsbox-diff`. +Run `uv run odsbox-diff --help` to see the available commands (`diff`, `collect`, +and `config`). Use `uv run odsbox-diff COMMAND --help` for command-specific +options. + ## Quick start 1. Create a starter config: ```powershell - uv run odsbox-diff create-config + uv run odsbox-diff config ``` Default output is `./odsbox-diff.config.toml` with three use-case server @@ -60,7 +64,7 @@ The package requires Python 3.14+ and ships a console script `odsbox-diff`. 3. Run the diff: ```powershell - uv run odsbox-diff ` + uv run odsbox-diff diff ` --config my-config.toml ` --entity TestStep ` -id1 5 ` @@ -70,7 +74,7 @@ The package requires Python 3.14+ and ships a console script `odsbox-diff`. With multiple named servers, prefix instance IDs with the server name: ```powershell - uv run odsbox-diff ` + uv run odsbox-diff diff ` --config my-config.toml ` --entity TestStep ` -id1 prod:1898 ` @@ -80,13 +84,16 @@ The package requires Python 3.14+ and ships a console script `odsbox-diff`. Compare two saved JSON files (no server connection needed): ```powershell - uv run odsbox-diff ` + uv run odsbox-diff diff ` --config my-config.toml ` --entity TestStep ` -id1 file:baseline.json ` -id2 file:current.json ``` + The historical form without the explicit `diff` subcommand still works for + backward compatibility. + Collect a hierarchy to a file and self-validate: ```powershell @@ -100,7 +107,7 @@ The package requires Python 3.14+ and ships a console script `odsbox-diff`. ## CLI reference -### `odsbox-diff` (diff mode) +### `odsbox-diff diff` (recommended diff mode) | Flag | Description | | --- | --- | @@ -121,6 +128,9 @@ CLI flags always override config defaults. List options (`exclude_path`, `exclude_regex_path`, `cached-related`) extend the config defaults rather than replacing them. +For backward compatibility, `odsbox-diff --config ... --entity ... -id1 ... -id2 ...` +still runs the diff command implicitly. + ### `odsbox-diff collect` (collect mode) | Flag | Description | @@ -137,7 +147,7 @@ replacing them. | `-v`, `--verbose` | INFO logging with timestamps. | | `-q`, `--quiet` | Suppress all logging. | -### `odsbox-diff create-config` (config scaffolding) +### `odsbox-diff config` (config scaffolding) | Flag | Description | | --- | --- | diff --git a/docs/usage.md b/docs/usage.md index 9e7b708..585d068 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -14,6 +14,9 @@ diffs. It works in three modes: Each mode is available both via the **CLI** (`odsbox-diff` command) and the **Python API** (importable functions for use in pytest and scripts). +Run `uv run odsbox-diff --help` to list the available commands. For detailed +options on one command, use `uv run odsbox-diff COMMAND --help`. + --- ## Installation & Setup @@ -42,7 +45,7 @@ environment: Or generate a new starter config directly: ```powershell -uv run odsbox-diff create-config +uv run odsbox-diff config ``` By default this writes `./odsbox-diff.config.toml` with three use-case server @@ -71,12 +74,13 @@ keyring set odsbox-diff "https://auth.example.com/token:my-client-id" ## CLI: Diff Mode -The default mode compares two hierarchy instances and writes a diff result file. +The recommended form is the explicit `diff` subcommand. The older bare form +without `diff` still works for backward compatibility. ### Two server instances ```powershell -uv run odsbox-diff ` +uv run odsbox-diff diff ` --config my-config.toml ` --entity TestStep ` -id1 5 ` @@ -86,7 +90,7 @@ uv run odsbox-diff ` With multiple named servers: ```powershell -uv run odsbox-diff ` +uv run odsbox-diff diff ` --config my-config.toml ` --entity TestStep ` -id1 prod:1898 ` @@ -99,7 +103,7 @@ Use the `file:` prefix to load a side from a previously saved JSON or ZIP file. No server connection is opened for file-based sides. ```powershell -uv run odsbox-diff ` +uv run odsbox-diff diff ` --config my-config.toml ` --entity TestStep ` -id1 file:baseline.json ` @@ -114,7 +118,7 @@ the `[server]` section can be omitted. Mix a file source with a server source: ```powershell -uv run odsbox-diff ` +uv run odsbox-diff diff ` --config my-config.toml ` --entity TestStep ` -id1 file:baseline.json ` @@ -208,14 +212,14 @@ uv run odsbox-diff collect ` ## CLI: Create Config -The `create-config` subcommand creates a new TOML config from the three example +The `config` subcommand creates a new TOML config from the three example files in `configs/`. ```powershell -uv run odsbox-diff create-config +uv run odsbox-diff config ``` -### Create-config flags +### Config flags | Flag | Description | | --- | --- | diff --git a/src/odsbox_diff/diff.py b/src/odsbox_diff/diff.py index 2a0d5b1..a25b3d4 100644 --- a/src/odsbox_diff/diff.py +++ b/src/odsbox_diff/diff.py @@ -29,6 +29,7 @@ "m2m": "production", "oidc": "staging", } +_CLI_SUBCOMMANDS = ("diff", "collect", "config") _SERVER_FIELD_ORDER = ( "url", "verify_certificate", @@ -502,11 +503,24 @@ def diff_ods_tests( return 0 if not diff_result else 100 -def _build_parser() -> argparse.ArgumentParser: +def _build_root_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="odsbox-diff", + description="Compare ODS hierarchies, collect snapshots, or generate starter configuration files.", + epilog="Use '%(prog)s COMMAND --help' for detailed command help. The 'diff' command also remains the implicit default for backward compatibility.", + ) + subparsers = parser.add_subparsers(dest="command", metavar="COMMAND") + subparsers.add_parser("diff", help="Compare two ODS instances and write a diff result.") + subparsers.add_parser("collect", help="Collect an ODS instance hierarchy into a JSON or ZIP file.") + subparsers.add_parser("config", help="Generate a starter config file from the bundled examples.") + return parser + + +def _build_diff_parser(prog: str = "odsbox-diff") -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=prog, description="Compare two Hierarchy instances of an ASAM ODS server and write a difference result file.", - epilog="Returns 0 if no changes where found.", + epilog="Returns 0 if no changes where found. Use 'odsbox-diff --help' to discover other commands.", ) parser.add_argument( "-c", @@ -620,16 +634,36 @@ def cli() -> None: (``0`` no differences, ``100`` differences found, ``-1`` on uncaught exception, ``1`` on argument validation errors). """ - # Dispatch to collect/create-config subcommands if requested - if len(sys.argv) > 1 and sys.argv[1] == "collect": - _cli_collect(sys.argv[2:]) + raw_args = sys.argv[1:] + root_parser = _build_root_parser() + + if not raw_args: + root_parser.print_help() + sys.exit(1) + + first_arg = raw_args[0] + if first_arg in ("-h", "--help"): + root_parser.parse_args(raw_args) + return + + if first_arg == "collect": + _cli_collect(raw_args[1:]) + return + if first_arg == "config": + _cli_create_config(raw_args[1:]) return - if len(sys.argv) > 1 and sys.argv[1] == "create-config": - _cli_create_config(sys.argv[2:]) + + diff_args = raw_args + diff_prog = "odsbox-diff" + if first_arg == "diff": + diff_args = raw_args[1:] + diff_prog = "odsbox-diff diff" + elif not first_arg.startswith("-"): + root_parser.parse_args(raw_args[:1]) return - parser = _build_parser() - args = parser.parse_args() + parser = _build_diff_parser(prog=diff_prog) + args = parser.parse_args(diff_args) # Load config (connection + defaults) app_config = load_config(args.config) @@ -859,7 +893,7 @@ def _build_collect_parser() -> argparse.ArgumentParser: def _build_create_config_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="odsbox-diff create-config", + prog="odsbox-diff config", description="Create a new config file from the bundled basic/m2m/oidc examples.", ) parser.add_argument( diff --git a/tests/test_diff.py b/tests/test_diff.py index 10f6643..9d9c99f 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -4,6 +4,7 @@ import tomllib from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -13,6 +14,7 @@ _cli_create_config, _parse_id_or_file, _parse_server_id, + cli, collect_ods_test, create_config_file, diff_ods_tests, @@ -414,3 +416,77 @@ def test_cli_create_config_existing_file_returns_exit_1(self, tmp_path: Path) -> _cli_create_config(["--output", str(output)]) assert ex.value.code == 1 + + +class TestCliHelpFlow: + def test_top_level_help_lists_subcommands(self, capsys: pytest.CaptureFixture[str]) -> None: + with patch("sys.argv", ["odsbox-diff", "--help"]): + with pytest.raises(SystemExit) as ex: + cli() + + assert ex.value.code == 0 + captured = capsys.readouterr() + assert "diff" in captured.out + assert "collect" in captured.out + assert "config" in captured.out + assert "COMMAND" in captured.out + + def test_unknown_subcommand_lists_valid_choices(self, capsys: pytest.CaptureFixture[str]) -> None: + with patch("sys.argv", ["odsbox-diff", "unknown"]): + with pytest.raises(SystemExit) as ex: + cli() + + assert ex.value.code == 2 + captured = capsys.readouterr() + assert "invalid choice" in captured.err + assert "diff" in captured.err + assert "collect" in captured.err + assert "config" in captured.err + + def test_explicit_diff_help_uses_diff_parser(self, capsys: pytest.CaptureFixture[str]) -> None: + with patch("sys.argv", ["odsbox-diff", "diff", "--help"]): + with pytest.raises(SystemExit) as ex: + cli() + + assert ex.value.code == 0 + captured = capsys.readouterr() + assert "--entity" in captured.out + assert "-id1" in captured.out + assert "odsbox-diff diff" in captured.out + + def test_no_args_prints_top_level_help(self, capsys: pytest.CaptureFixture[str]) -> None: + with patch("sys.argv", ["odsbox-diff"]): + with pytest.raises(SystemExit) as ex: + cli() + + assert ex.value.code == 1 + captured = capsys.readouterr() + assert "COMMAND" in captured.out + assert "collect" in captured.out + + def test_implicit_diff_invocation_still_works(self, tmp_path: Path) -> None: + defaults = SimpleNamespace( + verbose=False, + quiet=False, + result_file=str(tmp_path / "default-result.json"), + dump_dictionaries=False, + no_bulk=False, + bulk_progress_bar=False, + exclude_regex_paths=[], + exclude_paths=[], + cached_related=[], + ) + app_config = SimpleNamespace(defaults=defaults, servers={"default": MagicMock()}, queries=None) + + with ( + patch( + "sys.argv", ["odsbox-diff", "--config", "cfg.toml", "--entity", "TestStep", "-id1", "1", "-id2", "2"] + ), + patch("odsbox_diff.diff.load_config", return_value=app_config), + patch("odsbox_diff.diff.diff_ods_tests", return_value=0) as mock_diff, + ): + with pytest.raises(SystemExit) as ex: + cli() + + assert ex.value.code == 0 + assert mock_diff.call_count == 1 diff --git a/uv.lock b/uv.lock index fd92864..a30ce83 100644 --- a/uv.lock +++ b/uv.lock @@ -735,7 +735,7 @@ oidc = [ [[package]] name = "odsbox-diff" -version = "1.0.2" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "deepdiff" },