Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `
Expand All @@ -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 `
Expand All @@ -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
Expand All @@ -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 |
| --- | --- |
Expand All @@ -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 |
Expand All @@ -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 |
| --- | --- |
Expand Down
22 changes: 13 additions & 9 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `
Expand All @@ -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 `
Expand All @@ -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 `
Expand All @@ -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 `
Expand Down Expand Up @@ -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 |
| --- | --- |
Expand Down
54 changes: 44 additions & 10 deletions src/odsbox_diff/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"m2m": "production",
"oidc": "staging",
}
_CLI_SUBCOMMANDS = ("diff", "collect", "config")
_SERVER_FIELD_ORDER = (
"url",
"verify_certificate",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
76 changes: 76 additions & 0 deletions tests/test_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import tomllib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest
Expand All @@ -13,6 +14,7 @@
_cli_create_config,
_parse_id_or_file,
_parse_server_id,
cli,
collect_ods_test,
create_config_file,
diff_ods_tests,
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading