odsbox-diff 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ """odsbox-diff: compare two ASAM ODS hierarchy instances."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .api import (
6
+ collect_to_file,
7
+ diff_file_to_file,
8
+ diff_file_to_server,
9
+ diff_server_to_server,
10
+ )
11
+ from .diff import collect_ods_test, diff_ods_tests
12
+ from .ods_diff_hierarchy import (
13
+ collect,
14
+ diff_dictionaries,
15
+ dump_diff_as_json,
16
+ load_collect_results,
17
+ save_collect_results,
18
+ )
19
+
20
+ __all__ = [
21
+ "collect_ods_test",
22
+ "collect_to_file",
23
+ "diff_file_to_file",
24
+ "diff_file_to_server",
25
+ "diff_ods_tests",
26
+ "diff_server_to_server",
27
+ "collect",
28
+ "diff_dictionaries",
29
+ "dump_diff_as_json",
30
+ "load_collect_results",
31
+ "save_collect_results",
32
+ ]
@@ -0,0 +1,5 @@
1
+ """Allow running the package with ``python -m odsbox_diff``."""
2
+
3
+ from .diff import cli
4
+
5
+ cli()
odsbox_diff/api.py ADDED
@@ -0,0 +1,245 @@
1
+ """High-level API for programmatic use in test frameworks and scripts.
2
+
3
+ All functions return :class:`~deepdiff.DeepDiff` objects (falsy when no
4
+ differences are found) and raise exceptions on errors — no ``sys.exit()``
5
+ calls. This makes them suitable for direct use in ``assert`` statements::
6
+
7
+ from odsbox_diff import diff_file_to_file
8
+ assert not diff_file_to_file("baseline.json", "current.json")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from deepdiff import DeepDiff
18
+
19
+ from .connection import AppConfig, ServerConfig, create_connection, load_config
20
+ from .ods_diff_hierarchy.collect import collect, load_collect_results, save_collect_results
21
+ from .ods_diff_hierarchy.diff import diff_dictionaries
22
+
23
+ _log = logging.getLogger(__name__)
24
+
25
+
26
+ def _resolve_config(config: str | Path | AppConfig) -> AppConfig:
27
+ """Return an :class:`AppConfig`, loading from disk when needed."""
28
+ if isinstance(config, AppConfig):
29
+ return config
30
+ return load_config(str(config))
31
+
32
+
33
+ def _resolve_server_and_id(
34
+ config: AppConfig,
35
+ inst_id: int | str,
36
+ ) -> tuple[ServerConfig, int | str | dict[str, Any]]:
37
+ """Resolve a server config and instance condition from *inst_id*.
38
+
39
+ *inst_id* may be a plain ``int``, a string integer (``"42"``),
40
+ a JSON condition string, a named query, or ``"server:42"`` for
41
+ multi-server configs.
42
+ """
43
+ from .diff import _parse_server_id
44
+
45
+ servers = config.servers
46
+ multi_server = len(servers) > 1
47
+ return _parse_server_id(str(inst_id), servers, config.queries, multi_server)
48
+
49
+
50
+ def _collect_from_server(
51
+ server_cfg: ServerConfig,
52
+ entity_name: str,
53
+ inst_id: int | str | dict[str, Any],
54
+ *,
55
+ no_bulk: bool = False,
56
+ bulk_progress_bar: bool = False,
57
+ cached_related: list[str] | None = None,
58
+ ) -> dict[Any, Any]:
59
+ """Connect to an ODS server and collect a hierarchy."""
60
+ log = _log
61
+ log.info("Connecting to %s", server_cfg.url)
62
+ with create_connection(server_cfg) as con_i:
63
+ result, _ = collect(
64
+ con_i,
65
+ entity_name,
66
+ inst_id,
67
+ calculate_bulk_hash=not no_bulk,
68
+ show_progress=bulk_progress_bar,
69
+ cached_related_entities=cached_related,
70
+ )
71
+ log.info("Connection closed")
72
+ return result
73
+
74
+
75
+ # ── Public API ───────────────────────────────────────────────────────────
76
+
77
+
78
+ def diff_file_to_file(
79
+ file1: str | Path,
80
+ file2: str | Path,
81
+ *,
82
+ exclude_regex_paths: list[str] | None = None,
83
+ exclude_paths: list[str] | None = None,
84
+ ) -> DeepDiff:
85
+ """Compare two previously saved hierarchy files.
86
+
87
+ No server connection or config file is required.
88
+
89
+ Args:
90
+ file1: Path to the first hierarchy JSON or ZIP file.
91
+ file2: Path to the second hierarchy JSON or ZIP file.
92
+ exclude_regex_paths: Extra regex exclusions appended to the defaults.
93
+ exclude_paths: Extra explicit path exclusions.
94
+
95
+ Returns:
96
+ A :class:`~deepdiff.DeepDiff` object. Falsy when no differences exist.
97
+ """
98
+ d1 = load_collect_results(str(file1))
99
+ d2 = load_collect_results(str(file2))
100
+ return diff_dictionaries(
101
+ d1,
102
+ d2,
103
+ exclude_regex_paths or [],
104
+ exclude_paths or [],
105
+ )
106
+
107
+
108
+ def diff_server_to_server(
109
+ config: str | Path | AppConfig,
110
+ entity_name: str,
111
+ inst1_id: int | str,
112
+ inst2_id: int | str,
113
+ *,
114
+ exclude_regex_paths: list[str] | None = None,
115
+ exclude_paths: list[str] | None = None,
116
+ no_bulk: bool = False,
117
+ cached_related: list[str] | None = None,
118
+ ) -> DeepDiff:
119
+ """Collect two hierarchies from ODS server(s) and diff them.
120
+
121
+ Args:
122
+ config: Path to a TOML/JSON config file **or** an :class:`AppConfig`
123
+ built in code.
124
+ entity_name: ODS entity name (e.g. ``"TestStep"``).
125
+ inst1_id: Instance ID or ``"server:id"`` string for the first side.
126
+ inst2_id: Instance ID or ``"server:id"`` string for the second side.
127
+ exclude_regex_paths: Extra regex exclusions appended to the defaults.
128
+ exclude_paths: Extra explicit path exclusions.
129
+ no_bulk: Skip hashing of bulk LocalColumn data.
130
+ cached_related: Entity names whose IDs are resolved to names.
131
+
132
+ Returns:
133
+ A :class:`~deepdiff.DeepDiff` object. Falsy when no differences exist.
134
+ """
135
+ app = _resolve_config(config)
136
+ cfg1, id1 = _resolve_server_and_id(app, inst1_id)
137
+ cfg2, id2 = _resolve_server_and_id(app, inst2_id)
138
+
139
+ d1 = _collect_from_server(cfg1, entity_name, id1, no_bulk=no_bulk, cached_related=cached_related)
140
+ d2 = _collect_from_server(cfg2, entity_name, id2, no_bulk=no_bulk, cached_related=cached_related)
141
+
142
+ return diff_dictionaries(
143
+ d1,
144
+ d2,
145
+ exclude_regex_paths or [],
146
+ exclude_paths or [],
147
+ )
148
+
149
+
150
+ def diff_file_to_server(
151
+ config: str | Path | AppConfig,
152
+ entity_name: str,
153
+ server_id: int | str,
154
+ baseline_file: str | Path,
155
+ *,
156
+ exclude_regex_paths: list[str] | None = None,
157
+ exclude_paths: list[str] | None = None,
158
+ no_bulk: bool = False,
159
+ cached_related: list[str] | None = None,
160
+ ) -> DeepDiff:
161
+ """Compare a saved baseline file against a live server hierarchy.
162
+
163
+ Typical use case: regression testing — verify that a server instance still
164
+ matches a known-good baseline.
165
+
166
+ Args:
167
+ config: Path to a TOML/JSON config file **or** an :class:`AppConfig`.
168
+ entity_name: ODS entity name (e.g. ``"TestStep"``).
169
+ server_id: Instance ID or ``"server:id"`` for the live side.
170
+ baseline_file: Path to the baseline hierarchy JSON or ZIP file.
171
+ exclude_regex_paths: Extra regex exclusions appended to the defaults.
172
+ exclude_paths: Extra explicit path exclusions.
173
+ no_bulk: Skip hashing of bulk LocalColumn data.
174
+ cached_related: Entity names whose IDs are resolved to names.
175
+
176
+ Returns:
177
+ A :class:`~deepdiff.DeepDiff` object. Falsy when no differences exist.
178
+ """
179
+ app = _resolve_config(config)
180
+ cfg, iid = _resolve_server_and_id(app, server_id)
181
+
182
+ baseline = load_collect_results(str(baseline_file))
183
+ live = _collect_from_server(cfg, entity_name, iid, no_bulk=no_bulk, cached_related=cached_related)
184
+
185
+ return diff_dictionaries(
186
+ baseline,
187
+ live,
188
+ exclude_regex_paths or [],
189
+ exclude_paths or [],
190
+ )
191
+
192
+
193
+ def collect_to_file(
194
+ config: str | Path | AppConfig,
195
+ entity_name: str,
196
+ inst_id: int | str,
197
+ output_file: str | Path,
198
+ *,
199
+ no_bulk: bool = False,
200
+ bulk_progress_bar: bool = False,
201
+ cached_related: list[str] | None = None,
202
+ validate: bool = False,
203
+ ) -> DeepDiff | None:
204
+ """Collect an ODS hierarchy and save it to a JSON or ZIP file.
205
+
206
+ When *validate* is ``True`` the file is reloaded immediately and compared
207
+ against the in-memory data to verify round-trip fidelity.
208
+
209
+ Args:
210
+ config: Path to a TOML/JSON config file **or** an :class:`AppConfig`.
211
+ entity_name: ODS entity name (e.g. ``"TestStep"``).
212
+ inst_id: Instance ID or ``"server:id"`` string.
213
+ output_file: Destination path (``.json`` or ``.zip``).
214
+ no_bulk: Skip hashing of bulk LocalColumn data.
215
+ bulk_progress_bar: Show a progress bar during bulk hashing.
216
+ cached_related: Entity names whose IDs are resolved to names.
217
+ validate: Perform a round-trip self-diff after saving.
218
+
219
+ Returns:
220
+ ``None`` when *validate* is ``False``.
221
+ A :class:`~deepdiff.DeepDiff` object when *validate* is ``True``
222
+ (falsy if round-trip is clean).
223
+ """
224
+ app = _resolve_config(config)
225
+ cfg, iid = _resolve_server_and_id(app, inst_id)
226
+
227
+ result = _collect_from_server(
228
+ cfg,
229
+ entity_name,
230
+ iid,
231
+ no_bulk=no_bulk,
232
+ bulk_progress_bar=bulk_progress_bar,
233
+ cached_related=cached_related,
234
+ )
235
+
236
+ out = str(output_file)
237
+ save_collect_results(out, result)
238
+ _log.info("Saved collected hierarchy to: %s", out)
239
+
240
+ if not validate:
241
+ return None
242
+
243
+ _log.info("Validating round-trip fidelity ...")
244
+ reloaded = load_collect_results(out)
245
+ return diff_dictionaries(reloaded, result, [], [])
@@ -0,0 +1,14 @@
1
+ """Connection subsystem: config models, config loading, and connection factory."""
2
+
3
+ from .config import AppConfig, AuthMethod, DiffDefaults, ServerConfig
4
+ from .factory import create_connection
5
+ from .manager import load_config
6
+
7
+ __all__ = [
8
+ "AppConfig",
9
+ "AuthMethod",
10
+ "DiffDefaults",
11
+ "ServerConfig",
12
+ "create_connection",
13
+ "load_config",
14
+ ]
@@ -0,0 +1,96 @@
1
+ """Configuration models for ODS server connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+
10
+ class AuthMethod(Enum):
11
+ BASIC = "basic"
12
+ M2M = "m2m"
13
+ OIDC = "oidc"
14
+
15
+
16
+ @dataclass
17
+ class ServerConfig:
18
+ """Connection and authentication settings for a single ODS server."""
19
+
20
+ url: str
21
+ auth_method: AuthMethod = AuthMethod.BASIC
22
+ verify_certificate: bool = True
23
+
24
+ # basic auth
25
+ username: str | None = None
26
+ password: str | None = None
27
+
28
+ # m2m / oidc shared
29
+ client_id: str | None = None
30
+ client_secret: str | None = None
31
+ token_endpoint: str | None = None
32
+ scope: list[str] | None = None
33
+
34
+ # oidc specific
35
+ redirect_uri: str | None = None
36
+ redirect_url_allow_insecure: bool = False
37
+ authorization_endpoint: str | None = None
38
+ login_timeout: int = 60
39
+ webfinger_path_prefix: str = ""
40
+
41
+ def validate(self) -> None:
42
+ """Raise ValueError if required fields for the chosen auth method are missing."""
43
+ if not self.url:
44
+ raise ValueError("Server URL is required.")
45
+
46
+ if self.auth_method == AuthMethod.BASIC:
47
+ if not self.username:
48
+ raise ValueError("Username is required for basic auth.")
49
+ if not self.password:
50
+ raise ValueError(
51
+ f"Password is required for basic auth. "
52
+ f"Store it in keyring service 'odsbox-diff' with key '{self.url}:{self.username}' "
53
+ f"or provide it in the config file."
54
+ )
55
+
56
+ elif self.auth_method == AuthMethod.M2M:
57
+ if not self.client_id:
58
+ raise ValueError("client_id is required for m2m auth.")
59
+ if not self.token_endpoint:
60
+ raise ValueError("token_endpoint is required for m2m auth.")
61
+ if not self.client_secret:
62
+ raise ValueError(
63
+ f"client_secret is required for m2m auth. "
64
+ f"Store it in keyring service 'odsbox-diff' with key '{self.token_endpoint}:{self.client_id}' "
65
+ f"or provide it in the config file."
66
+ )
67
+
68
+ elif self.auth_method == AuthMethod.OIDC:
69
+ if not self.client_id:
70
+ raise ValueError("client_id is required for oidc auth.")
71
+ if not self.redirect_uri:
72
+ raise ValueError("redirect_uri is required for oidc auth.")
73
+
74
+
75
+ @dataclass
76
+ class DiffDefaults:
77
+ """Default diff behavior settings loadable from config."""
78
+
79
+ exclude_regex_paths: list[str] = field(default_factory=list[str])
80
+ exclude_paths: list[str] = field(default_factory=list[str])
81
+ bulk_progress_bar: bool = False
82
+ no_bulk: bool = False
83
+ dump_dictionaries: bool = False
84
+ result_file: str = "diff_ods_tests_result.json"
85
+ verbose: bool = False
86
+ quiet: bool = False
87
+ cached_related: list[str] = field(default_factory=list[str])
88
+
89
+
90
+ @dataclass
91
+ class AppConfig:
92
+ """Top-level application config combining named server(s) and diff settings."""
93
+
94
+ servers: dict[str, ServerConfig]
95
+ defaults: DiffDefaults = field(default_factory=DiffDefaults)
96
+ queries: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]])
@@ -0,0 +1,52 @@
1
+ """Create ConI connections from ServerConfig using odsbox ConIFactory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from odsbox import ConI
8
+ from odsbox import ConIFactory
9
+
10
+ from .config import AuthMethod, ServerConfig
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ def create_connection(cfg: ServerConfig) -> ConI:
16
+ """Return an opened ConI for the given server configuration."""
17
+ log.debug("Creating %s connection to %s", cfg.auth_method.value, cfg.url)
18
+
19
+ if cfg.auth_method == AuthMethod.BASIC:
20
+ return ConIFactory.basic(
21
+ url=cfg.url,
22
+ username=cfg.username, # type: ignore[arg-type]
23
+ password=cfg.password, # type: ignore[arg-type]
24
+ verify_certificate=cfg.verify_certificate,
25
+ )
26
+
27
+ if cfg.auth_method == AuthMethod.M2M:
28
+ return ConIFactory.m2m(
29
+ url=cfg.url,
30
+ token_endpoint=cfg.token_endpoint, # type: ignore[arg-type]
31
+ client_id=cfg.client_id, # type: ignore[arg-type]
32
+ client_secret=cfg.client_secret, # type: ignore[arg-type]
33
+ scope=cfg.scope,
34
+ verify_certificate=cfg.verify_certificate,
35
+ )
36
+
37
+ if cfg.auth_method == AuthMethod.OIDC:
38
+ return ConIFactory.oidc(
39
+ url=cfg.url,
40
+ client_id=cfg.client_id, # type: ignore[arg-type]
41
+ redirect_uri=cfg.redirect_uri, # type: ignore[arg-type]
42
+ redirect_url_allow_insecure=cfg.redirect_url_allow_insecure,
43
+ client_secret=cfg.client_secret,
44
+ scope=cfg.scope,
45
+ authorization_endpoint=cfg.authorization_endpoint,
46
+ token_endpoint=cfg.token_endpoint,
47
+ login_timeout=cfg.login_timeout,
48
+ verify_certificate=cfg.verify_certificate,
49
+ webfinger_path_prefix=cfg.webfinger_path_prefix,
50
+ )
51
+
52
+ raise ValueError(f"Unsupported auth method: {cfg.auth_method}")
@@ -0,0 +1,150 @@
1
+ """Config file loading, validation, and keyring-backed secret resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import tomllib
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import keyring
12
+
13
+ from .config import AppConfig, AuthMethod, DiffDefaults, ServerConfig
14
+
15
+ _KEYRING_SERVICE = "odsbox-diff"
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ def _keyring_key_for_basic(url: str, username: str) -> str:
21
+ return f"{url}:{username}"
22
+
23
+
24
+ def _keyring_key_for_client(token_endpoint: str, client_id: str) -> str:
25
+ return f"{token_endpoint}:{client_id}"
26
+
27
+
28
+ def _resolve_secret(cfg: ServerConfig) -> None:
29
+ """Fill in missing secrets from keyring. Mutates *cfg* in place."""
30
+ if cfg.auth_method == AuthMethod.BASIC and not cfg.password:
31
+ if cfg.username:
32
+ key = _keyring_key_for_basic(cfg.url, cfg.username)
33
+ secret = keyring.get_password(_KEYRING_SERVICE, key)
34
+ if secret:
35
+ log.debug("Resolved password from keyring for key '%s'.", key)
36
+ cfg.password = secret
37
+
38
+ elif cfg.auth_method in (AuthMethod.M2M, AuthMethod.OIDC) and not cfg.client_secret:
39
+ if cfg.client_id and cfg.token_endpoint:
40
+ key = _keyring_key_for_client(cfg.token_endpoint, cfg.client_id)
41
+ secret = keyring.get_password(_KEYRING_SERVICE, key)
42
+ if secret:
43
+ log.debug("Resolved client_secret from keyring for key '%s'.", key)
44
+ cfg.client_secret = secret
45
+
46
+
47
+ def _parse_server(raw: dict[str, Any]) -> ServerConfig:
48
+ """Build a ServerConfig from a merged server/auth section dict."""
49
+ auth_method_str = raw.get("method", "basic")
50
+ try:
51
+ auth_method = AuthMethod(auth_method_str)
52
+ except ValueError:
53
+ raise ValueError(f"Unknown auth method '{auth_method_str}'. Use one of: basic, m2m, oidc.")
54
+
55
+ scope_raw = raw.get("scope")
56
+ scope = scope_raw if isinstance(scope_raw, list) else None
57
+
58
+ return ServerConfig(
59
+ url=raw.get("url", ""),
60
+ auth_method=auth_method,
61
+ verify_certificate=raw.get("verify_certificate", True),
62
+ username=raw.get("username"),
63
+ password=raw.get("password"),
64
+ client_id=raw.get("client_id"),
65
+ client_secret=raw.get("client_secret"),
66
+ token_endpoint=raw.get("token_endpoint"),
67
+ scope=scope,
68
+ redirect_uri=raw.get("redirect_uri"),
69
+ redirect_url_allow_insecure=raw.get("redirect_url_allow_insecure", False),
70
+ authorization_endpoint=raw.get("authorization_endpoint"),
71
+ login_timeout=raw.get("login_timeout", 60),
72
+ webfinger_path_prefix=raw.get("webfinger_path_prefix", ""),
73
+ )
74
+
75
+
76
+ def _parse_config(raw: dict[str, Any]) -> AppConfig:
77
+ """Build an AppConfig from a parsed TOML/JSON dictionary."""
78
+ servers: dict[str, ServerConfig] = {}
79
+
80
+ if "servers" in raw:
81
+ # New format: one [servers.<name>] section per server
82
+ for name, srv_raw in raw["servers"].items():
83
+ if isinstance(srv_raw, dict):
84
+ servers[name] = _parse_server(srv_raw)
85
+ elif "server" in raw:
86
+ # New format: single unnamed [server] section
87
+ servers["default"] = _parse_server(raw["server"])
88
+ elif "connection" in raw or "authentication" in raw:
89
+ # Backward-compat: old separate [connection] + [authentication] sections
90
+ conn = raw.get("connection", {})
91
+ auth = raw.get("authentication", {})
92
+ servers["default"] = _parse_server({**conn, **auth})
93
+ # else: no server section — allowed when both diff sides are file sources
94
+ # and only [defaults] is needed from the config.
95
+
96
+ defaults_raw = raw.get("defaults", {})
97
+ erp = defaults_raw.get("exclude_regex_paths", [])
98
+ ep = defaults_raw.get("exclude_paths", [])
99
+ cr = defaults_raw.get("cached_related", [])
100
+ defaults = DiffDefaults(
101
+ exclude_regex_paths=erp if isinstance(erp, list) else [],
102
+ exclude_paths=ep if isinstance(ep, list) else [],
103
+ bulk_progress_bar=defaults_raw.get("bulk_progress_bar", False),
104
+ no_bulk=defaults_raw.get("no_bulk", False),
105
+ dump_dictionaries=defaults_raw.get("dump_dictionaries", False),
106
+ result_file=defaults_raw.get("result_file", "diff_ods_tests_result.json"),
107
+ verbose=defaults_raw.get("verbose", False),
108
+ quiet=defaults_raw.get("quiet", False),
109
+ cached_related=cr if isinstance(cr, list) else [],
110
+ )
111
+
112
+ queries: list[dict[str, Any]] = []
113
+ for query_name, query_raw in raw.get("queries", {}).items():
114
+ if isinstance(query_raw, dict) and "condition" in query_raw:
115
+ condition_raw = query_raw["condition"]
116
+ condition = json.loads(condition_raw) if isinstance(condition_raw, str) else condition_raw
117
+ queries.append({"name": query_name, "condition": condition})
118
+
119
+ return AppConfig(servers=servers, defaults=defaults, queries=queries)
120
+
121
+
122
+ def load_config(path: str | Path) -> AppConfig:
123
+ """Load, validate, and return an AppConfig from a TOML or JSON file.
124
+
125
+ Secrets missing from the file are resolved from the OS keyring before
126
+ validation so that the returned config is ready for connection creation.
127
+ """
128
+ p = Path(path)
129
+ if not p.is_file():
130
+ raise FileNotFoundError(f"Config file not found: {p}")
131
+
132
+ text = p.read_text(encoding="utf-8")
133
+
134
+ if p.suffix in (".toml",):
135
+ raw = tomllib.loads(text)
136
+ elif p.suffix in (".json",):
137
+ raw = json.loads(text)
138
+ else:
139
+ # Try TOML first, fall back to JSON
140
+ try:
141
+ raw = tomllib.loads(text)
142
+ except Exception:
143
+ raw = json.loads(text)
144
+
145
+ app_config = _parse_config(raw)
146
+ for cfg in app_config.servers.values():
147
+ _resolve_secret(cfg)
148
+ cfg.validate()
149
+
150
+ return app_config