diff --git a/README.md b/README.md index ef5e916..bb1debd 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,16 @@ The package requires Python 3.14+ and ships a console script `odsbox-diff`. ## Quick start -1. Copy one of the example configs from `configs/` and adjust it: +1. Create a starter config: + + ```powershell + uv run odsbox-diff create-config + ``` + + Default output is `./odsbox-diff.config.toml` with three use-case server + entries: `default` (basic), `production` (m2m), and `staging` (oidc). + + Or copy one of the example configs from `configs/` and adjust it: - `config.example.toml` — basic auth (single or multiple servers) - `config.m2m.example.toml` — OAuth2 machine-to-machine @@ -128,6 +137,16 @@ replacing them. | `-v`, `--verbose` | INFO logging with timestamps. | | `-q`, `--quiet` | Suppress all logging. | +### `odsbox-diff create-config` (config scaffolding) + +| Flag | Description | +| --- | --- | +| `-o`, `--output` | Output path (default: `./odsbox-diff.config.toml`). | +| `--force` | Overwrite output file if it already exists. | +| `--single-auth {basic,m2m,oidc}` | Generate only one server section. | +| `--with-queries` / `--no-queries` | Include or skip `queries.first` and `queries.second`. | +| `--include-example-comments` / `--minimal` | Verbose guided output or compact output without comments. | + ## Exit codes | Code | Meaning | @@ -210,6 +229,9 @@ dump_diff_as_json("diff.json", diff) Either a single `[server]` table (stored internally as the `default` server) or one `[servers.]` table per named server. Common fields: +For single-server usage, `[servers.default]` is supported and behaves the same +as `[server]`. + | Field | Type | Notes | | --- | --- | --- | | `url` | string | ODS REST endpoint (required). | diff --git a/configs/config.example.toml b/configs/config.example.toml index 95bd037..0fc14d5 100644 --- a/configs/config.example.toml +++ b/configs/config.example.toml @@ -2,26 +2,26 @@ # Copy this file and adjust values for your environment. # # Usage (single server): -# uv run odsbox-diff --config config.toml -entity TestStep -id1 2 -id2 3 +# uv run odsbox-diff --config config.example.toml -entity TestStep -id1 2 -id2 3 # # Usage (compare across two named servers): -# uv run odsbox-diff --config config.toml -entity TestStep -id1 2 -id2 3 --server1 production --server2 staging +# uv run odsbox-diff --config config.example.toml -entity TestStep -id1 production:2 -id2 staging:3 # ── Single server ──────────────────────────────────────────────────── -# Use [server] when comparing two instances on the same server. -[server] +# Use [servers.default] when comparing two instances on the same server. +# No need to specify the server name in id declarations, e.g. -id1 2 -id2 3. +[servers.default] url = "http://localhost:57481/api" verify_certificate = false # set true for production with valid TLS method = "basic" username = "admin" # password = "admin" # prefer keyring over plaintext! # To store in keyring: -# python -c "import keyring; keyring.set_password('odsbox-diff', 'http://localhost:57481/api:admin', 'admin')" +# keyring set odsbox-diff http://localhost:57481/api:admin # ── Multiple named servers ─────────────────────────────────────────── # Use [servers.] to define two or more servers. -# Pick them at runtime with --server1 --server2 . -# If only one server is defined below, --server1/--server2 are not required. +# Pick them at runtime as prefix in the id declarations, e.g. -id1 production:2 -id2 staging:3. # # [servers.production] # url = "https://prod.example.com/api" diff --git a/configs/config.m2m.example.toml b/configs/config.m2m.example.toml index 6e81cba..0914f4d 100644 --- a/configs/config.m2m.example.toml +++ b/configs/config.m2m.example.toml @@ -1,7 +1,7 @@ # odsbox-diff configuration — M2M (machine-to-machine) authentication example # # Usage: -# uv run odsbox-diff --config config.m2m.toml -entity TestStep -id1 2 -id2 3 +# uv run odsbox-diff --config config.m2m.example.toml -entity TestStep -id1 2 -id2 3 # ── Server (connection + authentication combined) ──────────────────── [server] @@ -13,7 +13,7 @@ token_endpoint = "https://auth.example.com/realms/myrealm/protocol/openid-connec scope = ["machine2machine"] # client_secret is intentionally omitted — it will be retrieved from keyring. # To store in keyring: -# python -c "import keyring; keyring.set_password('odsbox-diff', 'https://auth.example.com/realms/myrealm/protocol/openid-connect/token:f0a8cec0-e980-48c4-9898-8a11f40da518', 'YOUR_SECRET')" +# keyring set odsbox-diff https://auth.example.com/realms/myrealm/protocol/openid-connect/token:f0a8cec0-e980-48c4-9898-8a11f40da518 # ── Diff defaults ──────────────────────────────────────────────────── [defaults] diff --git a/configs/config.oidc.example.toml b/configs/config.oidc.example.toml index 3400ede..b3e6eeb 100644 --- a/configs/config.oidc.example.toml +++ b/configs/config.oidc.example.toml @@ -1,7 +1,7 @@ # odsbox-diff configuration — OIDC (interactive browser login) authentication example # # Usage: -# uv run odsbox-diff --config config.oidc.toml -entity TestStep -id1 2 -id2 3 +# uv run odsbox-diff --config config.oidc.example.toml -entity TestStep -id1 2 -id2 3 # ── Server (connection + authentication combined) ──────────────────── [server] @@ -19,7 +19,7 @@ webfinger_path_prefix = "/ods" # optional, for non-standard WebFinger pat # authorization_endpoint = "https://auth.example.com/authorize" # token_endpoint = "https://auth.example.com/token" # client_secret is optional for OIDC; if needed, store it in keyring: -# python -c "import keyring; keyring.set_password('odsbox-diff', 'https://auth.example.com/token:14df520f-f6b0-4135-be1b-bff3f5cc474c', 'YOUR_SECRET')" +# keyring set https://auth.example.com/token:14df520f-f6b0-4135-be1b-bff3f5cc474c # ── Diff defaults ──────────────────────────────────────────────────── [defaults] diff --git a/docs/usage.md b/docs/usage.md index 289f6e4..9e7b708 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -39,6 +39,16 @@ environment: | `config.m2m.example.toml` | OAuth2 machine-to-machine | | `config.oidc.example.toml` | OIDC (interactive browser login) | +Or generate a new starter config directly: + +```powershell +uv run odsbox-diff create-config +``` + +By default this writes `./odsbox-diff.config.toml` with three use-case server +entries (`default=basic`, `production=m2m`, `staging=oidc`) and two query placeholders +(`queries.first`, `queries.second`). + ### Keyring secrets Rather than storing secrets in the config file, store them in your OS keyring @@ -196,6 +206,27 @@ uv run odsbox-diff collect ` --- +## CLI: Create Config + +The `create-config` subcommand creates a new TOML config from the three example +files in `configs/`. + +```powershell +uv run odsbox-diff create-config +``` + +### Create-config flags + +| Flag | Description | +| --- | --- | +| `-o`, `--output` | Output path (default: `./odsbox-diff.config.toml`). | +| `--force` | Overwrite output file if it already exists. | +| `--single-auth {basic,m2m,oidc}` | Generate only one server section (`default`/`production`/`staging`). | +| `--with-queries` / `--no-queries` | Include or skip `queries.first` and `queries.second`. | +| `--include-example-comments` / `--minimal` | Verbose guided output or compact output without comments. | + +--- + ## Exit Codes | Code | Meaning | @@ -214,6 +245,9 @@ uv run odsbox-diff collect ` Either a single `[server]` table or one `[servers.]` table per named server. +For single-server usage, `[servers.default]` is also valid and behaves like +`[server]`. + | Field | Type | Default | Notes | | --- | --- | --- | --- | | `url` | string | — | ODS REST endpoint (required). | diff --git a/src/odsbox_diff/__init__.py b/src/odsbox_diff/__init__.py index 713b65c..67ed7ee 100644 --- a/src/odsbox_diff/__init__.py +++ b/src/odsbox_diff/__init__.py @@ -8,7 +8,7 @@ diff_file_to_server, diff_server_to_server, ) -from .diff import collect_ods_test, diff_ods_tests +from .diff import collect_ods_test, create_config_file, diff_ods_tests from .ods_diff_hierarchy import ( collect, diff_dictionaries, @@ -20,6 +20,7 @@ __all__ = [ "collect_ods_test", "collect_to_file", + "create_config_file", "diff_file_to_file", "diff_file_to_server", "diff_ods_tests", diff --git a/src/odsbox_diff/diff.py b/src/odsbox_diff/diff.py index 2402792..2a0d5b1 100644 --- a/src/odsbox_diff/diff.py +++ b/src/odsbox_diff/diff.py @@ -4,6 +4,8 @@ import json import logging import sys +import tomllib +from pathlib import Path from typing import Any, cast import urllib3 @@ -16,6 +18,242 @@ urllib3.disable_warnings() +_DEFAULT_CONFIG_OUTPUT = "./odsbox-diff.config.toml" +_EXAMPLE_FILE_BY_AUTH: dict[str, str] = { + "basic": "config.example.toml", + "m2m": "config.m2m.example.toml", + "oidc": "config.oidc.example.toml", +} +_USE_CASE_NAME_BY_AUTH: dict[str, str] = { + "basic": "default", + "m2m": "production", + "oidc": "staging", +} +_SERVER_FIELD_ORDER = ( + "url", + "verify_certificate", + "method", + "username", + "password", + "client_id", + "client_secret", + "token_endpoint", + "scope", + "redirect_uri", + "redirect_url_allow_insecure", + "authorization_endpoint", + "login_timeout", + "webfinger_path_prefix", +) + + +def _example_config_path(auth_method: str) -> Path: + root = Path(__file__).resolve().parents[2] + filename = _EXAMPLE_FILE_BY_AUTH[auth_method] + path = root / "configs" / filename + if not path.is_file(): + raise FileNotFoundError(f"Example config file not found: {path}") + return path + + +def _load_example_template(auth_method: str) -> dict[str, Any]: + template_path = _example_config_path(auth_method) + return tomllib.loads(template_path.read_text(encoding="utf-8")) + + +def _toml_scalar(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, str): + return json.dumps(value) + raise TypeError(f"Unsupported TOML scalar type: {type(value)!r}") + + +def _toml_value(value: Any, *, multiline_lists: bool = False) -> str: + if isinstance(value, list): + if not value: + return "[]" + if multiline_lists: + lines = ["["] + for item in value: + lines.append(f" {_toml_scalar(item)},") + lines.append("]") + return "\n".join(lines) + parts = ", ".join(_toml_scalar(v) for v in value) + return f"[{parts}]" + return _toml_scalar(value) + + +def _template_server_section(template: dict[str, Any]) -> dict[str, Any]: + server_raw = template.get("server") + if isinstance(server_raw, dict): + return cast(dict[str, Any], server_raw) + + servers_raw = template.get("servers") + if isinstance(servers_raw, dict): + default_raw = servers_raw.get("default") + if isinstance(default_raw, dict): + return cast(dict[str, Any], default_raw) + + return {} + + +def _secret_help_lines(auth: str, server_raw: dict[str, Any]) -> list[str]: + if auth == "basic": + url = cast(str, server_raw.get("url", "http://localhost:57481/api")) + username = cast(str, server_raw.get("username", "admin")) + return [ + '# password = "admin" # prefer keyring over plaintext!', + "# To store in keyring:", + f"# keyring set odsbox-diff {url}:{username}", + ] + + if auth in ("m2m", "oidc"): + token_endpoint = cast(str | None, server_raw.get("token_endpoint")) + client_id = cast(str, server_raw.get("client_id", "my-client-id")) + if token_endpoint: + return [ + "# client_secret is intentionally omitted — it will be retrieved from keyring.", + "# To store in keyring:", + f"# keyring set odsbox-diff {token_endpoint}:{client_id}", + ] + return [ + "# client_secret can be stored in keyring when token_endpoint is configured.", + "# Key format: :", + f"# Example client_id: {client_id}", + ] + + return [] + + +def _server_keys_for_mode(server_raw: dict[str, Any], minimal: bool) -> list[str]: + if not minimal: + return [k for k in _SERVER_FIELD_ORDER if k in server_raw] + + method = cast(str, server_raw.get("method", "basic")) + required = ["url", "verify_certificate", "method"] + if method == "basic": + required.extend(["username"]) + elif method == "m2m": + required.extend(["client_id", "token_endpoint", "scope"]) + elif method == "oidc": + required.extend(["client_id", "redirect_uri", "scope"]) + return [k for k in required if k in server_raw] + + +def _format_condition_block(condition: Any, minimal: bool) -> str: + if isinstance(condition, dict): + condition_text = json.dumps(condition, indent=4) + elif isinstance(condition, str): + try: + condition_text = json.dumps(json.loads(condition), indent=4) + except json.JSONDecodeError: + condition_text = condition + else: + condition_text = json.dumps(condition, indent=4) + + return f"condition = '''{condition_text}'''" + + +def _build_generated_config_text( + single_auth: str | None, + with_queries: bool, + include_example_comments: bool, + config_ref: str, +) -> str: + selected_auth = [single_auth] if single_auth else ["basic", "m2m", "oidc"] + minimal = not include_example_comments + multiline_lists = True + + templates = {auth: _load_example_template(auth) for auth in selected_auth} + defaults_raw = cast(dict[str, Any], _load_example_template("basic").get("defaults", {})) + queries_raw = cast(dict[str, Any], _load_example_template("basic").get("queries", {})) + + lines: list[str] = [] + if include_example_comments: + lines.extend( + [ + "# odsbox-diff configuration file", + "#", + "# Usage (default server):", + f"# uv run odsbox-diff --config {config_ref} -entity TestStep -id1 2 -id2 3", + "#", + "# Usage (compare across two named servers by id or named query):", + f"# uv run odsbox-diff --config {config_ref} -entity TestStep -id1 production:2 -id2 staging:3", + f"# uv run odsbox-diff --config {config_ref} -entity TestStep -id1 production:first -id2 staging:first", + "", + ] + ) + + for auth in selected_auth: + template = templates[auth] + server_raw = _template_server_section(template) + server_name = _USE_CASE_NAME_BY_AUTH[auth] + if include_example_comments: + lines.append(f"# {server_name} server ({auth} auth)") + lines.append(f"[servers.{server_name}]") + for key in _server_keys_for_mode(server_raw, minimal=minimal): + lines.append(f"{key} = {_toml_value(server_raw[key], multiline_lists=multiline_lists)}") + if include_example_comments: + lines.extend(_secret_help_lines(auth, server_raw)) + lines.append("") + + if include_example_comments: + lines.append("# Diff defaults") + lines.append("[defaults]") + for key in ( + "bulk_progress_bar", + "no_bulk", + "dump_dictionaries", + "result_file", + "verbose", + "exclude_regex_paths", + "exclude_paths", + "cached_related", + ): + if key in defaults_raw: + lines.append(f"{key} = {_toml_value(defaults_raw[key], multiline_lists=multiline_lists)}") + lines.append("") + + if with_queries: + for query_name in ("first", "second"): + query = cast(dict[str, Any] | None, queries_raw.get(query_name)) + if not query or "condition" not in query: + continue + if include_example_comments: + lines.append(f"# Named query: {query_name}") + lines.append(f"[queries.{query_name}]") + lines.append(_format_condition_block(query["condition"], minimal=minimal)) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def create_config_file( + output_path: str, + *, + force: bool = False, + single_auth: str | None = None, + with_queries: bool = True, + include_example_comments: bool = True, +) -> Path: + output = Path(output_path) + if output.exists() and not force: + raise FileExistsError(f"Config file already exists: {output}. Use --force to overwrite.") + + output.parent.mkdir(parents=True, exist_ok=True) + config_ref = output.name + content = _build_generated_config_text( + single_auth=single_auth, + with_queries=with_queries, + include_example_comments=include_example_comments, + config_ref=config_ref, + ) + output.write_text(content, encoding="utf-8") + return output + def _parse_id_string(id_string: str | int, queries: list[dict[str, Any]] | None) -> int | dict[str, Any] | str: if isinstance(id_string, str): @@ -382,10 +620,13 @@ def cli() -> None: (``0`` no differences, ``100`` differences found, ``-1`` on uncaught exception, ``1`` on argument validation errors). """ - # Dispatch to collect subcommand if requested + # Dispatch to collect/create-config subcommands if requested if len(sys.argv) > 1 and sys.argv[1] == "collect": _cli_collect(sys.argv[2:]) return + if len(sys.argv) > 1 and sys.argv[1] == "create-config": + _cli_create_config(sys.argv[2:]) + return parser = _build_parser() args = parser.parse_args() @@ -616,6 +857,89 @@ def _build_collect_parser() -> argparse.ArgumentParser: return parser +def _build_create_config_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="odsbox-diff create-config", + description="Create a new config file from the bundled basic/m2m/oidc examples.", + ) + parser.add_argument( + "-o", + "--output", + dest="output", + type=str, + default=_DEFAULT_CONFIG_OUTPUT, + help=f"Output config file path (default: {_DEFAULT_CONFIG_OUTPUT}).", + ) + parser.add_argument( + "--force", + dest="force", + action="store_true", + default=False, + help="Overwrite output file if it already exists.", + ) + parser.add_argument( + "--single-auth", + dest="single_auth", + choices=["basic", "m2m", "oidc"], + default=None, + help="Generate a single server section for one auth method only.", + ) + + query_group = parser.add_mutually_exclusive_group() + query_group.add_argument( + "--with-queries", + dest="with_queries", + action="store_true", + default=True, + help="Include [queries.first] and [queries.second] sections (default).", + ) + query_group.add_argument( + "--no-queries", + dest="with_queries", + action="store_false", + help="Do not include query sections.", + ) + + style_group = parser.add_mutually_exclusive_group() + style_group.add_argument( + "--include-example-comments", + dest="include_example_comments", + action="store_true", + default=True, + help="Include guidance comments in the generated config (default).", + ) + style_group.add_argument( + "--minimal", + dest="include_example_comments", + action="store_false", + help="Write a compact config with no comments and only required server fields.", + ) + return parser + + +def _cli_create_config(raw_args: list[str]) -> None: + parser = _build_create_config_parser() + args = parser.parse_args(raw_args) + log = logging.getLogger(__name__) + + try: + out = create_config_file( + output_path=args.output, + force=args.force, + single_auth=args.single_auth, + with_queries=args.with_queries, + include_example_comments=args.include_example_comments, + ) + log.info("Created config file: %s", out) + sys.exit(0) + except FileExistsError as e: + log.error("%s", e) + sys.exit(1) + except Exception as e: + log.exception("Exception: %s", e) + sys.exit(-1) + + def _cli_collect(raw_args: list[str]) -> None: """Parse and execute the ``odsbox-diff collect`` subcommand.""" parser = _build_collect_parser() diff --git a/tests/connection/test_manager.py b/tests/connection/test_manager.py index 1df78e0..3b74a14 100644 --- a/tests/connection/test_manager.py +++ b/tests/connection/test_manager.py @@ -89,6 +89,33 @@ def test_multi_server(self, raw_multi_server_config: dict[str, object]) -> None: app = _parse_config(raw_multi_server_config) assert set(app.servers.keys()) == {"prod", "staging"} + def test_servers_default_single_server(self) -> None: + app = _parse_config( + { + "servers": { + "default": { + "url": "http://localhost:8080/api", + "method": "basic", + "username": "admin", + "password": "secret", + } + } + } + ) + assert set(app.servers.keys()) == {"default"} + assert app.servers["default"].url == "http://localhost:8080/api" + + def test_servers_default_with_named_servers(self) -> None: + app = _parse_config( + { + "servers": { + "default": {"url": "http://x", "username": "u", "password": "p"}, + "staging": {"url": "http://s", "username": "u", "password": "p"}, + } + } + ) + assert set(app.servers.keys()) == {"default", "staging"} + def test_legacy_connection_authentication(self) -> None: raw = { "connection": {"url": "http://x", "verify_certificate": False}, diff --git a/tests/test_diff.py b/tests/test_diff.py index 46173f1..10f6643 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -2,13 +2,21 @@ from __future__ import annotations +import tomllib from pathlib import Path from unittest.mock import MagicMock, patch import pytest from odsbox_diff.connection.config import AuthMethod, ServerConfig -from odsbox_diff.diff import _parse_id_or_file, _parse_server_id, collect_ods_test, diff_ods_tests +from odsbox_diff.diff import ( + _cli_create_config, + _parse_id_or_file, + _parse_server_id, + collect_ods_test, + create_config_file, + diff_ods_tests, +) @pytest.fixture @@ -322,3 +330,87 @@ def test_collect_validate_diff_found(self, tmp_path: Path, basic_server_cfg: Ser ) assert rc == 100 + + +class TestCreateConfig: + def test_create_config_default_generates_three_servers_and_queries(self, tmp_path: Path) -> None: + output = tmp_path / "generated.toml" + + created = create_config_file(str(output)) + assert created == output + + text = output.read_text(encoding="utf-8") + assert "--config generated.toml" in text + assert "exclude_regex_paths = [\n" in text + assert "[servers.default]\nurl =" in text + assert '# password = "admin" # prefer keyring over plaintext!' in text + assert "# keyring set odsbox-diff http://localhost:57481/api:admin" in text + assert ( + "# keyring set odsbox-diff https://auth.example.com/realms/myrealm/protocol/openid-connect/token:f0a8cec0-e980-48c4-9898-8a11f40da518" + in text + ) + assert "# client_secret can be stored in keyring when token_endpoint is configured." in text + assert "# Key format: :" in text + + raw = tomllib.loads(text) + assert set(raw["servers"].keys()) == {"default", "production", "staging"} + assert raw["servers"]["default"]["method"] == "basic" + assert raw["servers"]["production"]["method"] == "m2m" + assert raw["servers"]["staging"]["method"] == "oidc" + assert "defaults" in raw + assert set(raw["queries"].keys()) == {"first", "second"} + + def test_create_config_single_auth_minimal_no_queries(self, tmp_path: Path) -> None: + output = tmp_path / "single-auth.toml" + + create_config_file( + str(output), + single_auth="m2m", + with_queries=False, + include_example_comments=False, + ) + + text = output.read_text(encoding="utf-8") + assert "#" not in text + + raw = tomllib.loads(text) + assert set(raw["servers"].keys()) == {"production"} + assert raw["servers"]["production"]["method"] == "m2m" + assert "queries" not in raw + + def test_create_config_minimal_multiline_defaults_and_conditions(self, tmp_path: Path) -> None: + output = tmp_path / "minimal.toml" + + create_config_file( + str(output), + include_example_comments=False, + with_queries=True, + ) + + text = output.read_text(encoding="utf-8") + assert "exclude_regex_paths = [\n" in text + assert "[queries.first]\ncondition = '''{\n" in text + + raw = tomllib.loads(text) + assert "exclude_regex_paths" in raw["defaults"] + assert "queries" in raw + + def test_create_config_force_required_for_overwrite(self, tmp_path: Path) -> None: + output = tmp_path / "existing.toml" + output.write_text('[defaults]\nresult_file = "x.json"\n', encoding="utf-8") + + with pytest.raises(FileExistsError): + create_config_file(str(output)) + + create_config_file(str(output), force=True, single_auth="basic") + raw = tomllib.loads(output.read_text(encoding="utf-8")) + assert set(raw["servers"].keys()) == {"default"} + + def test_cli_create_config_existing_file_returns_exit_1(self, tmp_path: Path) -> None: + output = tmp_path / "existing.toml" + output.write_text("already there", encoding="utf-8") + + with pytest.raises(SystemExit) as ex: + _cli_create_config(["--output", str(output)]) + + assert ex.value.code == 1 diff --git a/uv.lock b/uv.lock index c7fcf01..fd92864 100644 --- a/uv.lock +++ b/uv.lock @@ -735,7 +735,7 @@ oidc = [ [[package]] name = "odsbox-diff" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "deepdiff" },