vibedata-odcs 0.1.0__tar.gz
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.
- vibedata_odcs-0.1.0/.gitignore +8 -0
- vibedata_odcs-0.1.0/CHANGELOG.md +10 -0
- vibedata_odcs-0.1.0/PKG-INFO +64 -0
- vibedata_odcs-0.1.0/README.md +45 -0
- vibedata_odcs-0.1.0/pyproject.toml +43 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/__init__.py +13 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/__main__.py +3 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/boundary.py +297 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/check.py +284 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/cli.py +92 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/contracts.py +188 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/diagnostics.py +68 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/diff.py +261 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/generate.py +644 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/model.py +314 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/profile.py +168 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/py.typed +0 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/results.py +183 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/reverse.py +433 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/schema.py +72 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/schemas/README.md +19 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/schemas/odcs-json-schema-v3.2.0.json +3648 -0
- vibedata_odcs-0.1.0/src/vibedata/odcs/warehouse.py +171 -0
- vibedata_odcs-0.1.0/tests/conftest.py +0 -0
- vibedata_odcs-0.1.0/tests/snapshots/diagnostic-codes.txt +52 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-check.txt +25 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-diff.txt +24 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-generate.txt +15 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-profile.txt +17 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-results.txt +18 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-reverse.txt +26 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-root.txt +21 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-validate.txt +18 -0
- vibedata_odcs-0.1.0/tests/snapshots/help-version.txt +8 -0
- vibedata_odcs-0.1.0/tests/snapshots/version-fields.txt +4 -0
- vibedata_odcs-0.1.0/tests/test_cli_contract.py +136 -0
- vibedata_odcs-0.1.0/tests/test_odcs.py +2614 -0
- vibedata_odcs-0.1.0/tests/test_odcs_commands.py +713 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
The public API is the `odcs` command line: its commands and options, the JSON diagnostics on stdout (each diagnostic's `code` included), the `results` record shape, and the exit codes. `vibedata.odcs.cli.main`, `vibedata.odcs.cli.app` and `vibedata.odcs.warehouse.set_destination_resolver` (re-exported, with `DestinationResolver`, from `vibedata.odcs.cli`) are the Python API. Everything else is internal. While the version is 0.x, a breaking change bumps the minor version.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
- First release. The console script is `vibedata-odcs` (also `python -m vibedata.odcs`). Ported from the plugin's `skills/data-contract/scripts/odcs.py` at vibedata-data-engineering `f73ce0b`, with no change to any command's behaviour or output.
|
|
8
|
+
- `version` reports the package version beside the standard and schema pins.
|
|
9
|
+
- The Intent's sandbox comes from a resolver the caller installs (`main(destination_resolver=...)`), not from the plugin's `platforms` module. With neither a resolver nor `--catalog`, a warehouse command refuses with `catalog/missing`.
|
|
10
|
+
- `duckdb` moves behind the `warehouse` extra.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vibedata-odcs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: VibeData data-contract tooling: check, generate, reverse, diff, profile and results for Open Data Contract Standard (ODCS v3.2.0) contracts at a Domain's boundary. One library and one `odcs` command shared by the Build agent's skills, Domain CI and Studio.
|
|
5
|
+
Project-URL: Repository, https://github.com/accelerate-data/vibedata-python-packages
|
|
6
|
+
Project-URL: Changelog, https://github.com/accelerate-data/vibedata-python-packages/blob/main/packages/vibedata-odcs/CHANGELOG.md
|
|
7
|
+
Author-email: Vibedata <eng@acceleratedata.ai>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: jsonschema>=4.18
|
|
11
|
+
Requires-Dist: pyyaml>=6
|
|
12
|
+
Requires-Dist: typer>=0.13
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: duckdb; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: warehouse
|
|
17
|
+
Requires-Dist: duckdb; extra == 'warehouse'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# vibedata-odcs
|
|
21
|
+
|
|
22
|
+
Data-contract tooling for [Open Data Contract Standard](https://github.com/bitol-io/open-data-contract-standard) (ODCS v3.2.0) contracts at a VibeData Domain's boundary.
|
|
23
|
+
One library and one `vibedata-odcs` command, shared by the Build agent's `data-contract` skills, Domain CI's logical-model gate, and Studio. It replaces the `odcs.py` script that was copied into each of them.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install vibedata-odcs # check, generate, diff, results, version
|
|
27
|
+
pip install "vibedata-odcs[warehouse]" # adds duckdb for check --relations, reverse and profile
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Run from the Domain's repository root:
|
|
31
|
+
|
|
32
|
+
| Command | What it does | Reads a warehouse |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `vibedata-odcs check` | schema and structure of the committed contracts, one per boundary relation, agreement with the canonical model | no |
|
|
35
|
+
| `vibedata-odcs check --relations` | adds the comparison against the built relation (or dbt's `catalog.json`) | yes |
|
|
36
|
+
| `vibedata-odcs reverse [--force]` | drafts the boundary's contracts from a built warehouse, with the open questions as findings | yes |
|
|
37
|
+
| `vibedata-odcs generate` | writes the dbt keys and the dlt `COLUMNS` hints from the contracts | no |
|
|
38
|
+
| `vibedata-odcs diff <base-ref>` | classifies each changed contract against a git base and requires the version bump it needs | no |
|
|
39
|
+
| `vibedata-odcs profile [--relation S.T]` | tests each contract's key, `required` and value-set claims against the built relation | yes |
|
|
40
|
+
| `vibedata-odcs results <run_results.json>` | maps a dbt run's test results to the contract assertions they enforce (JSON lines) | no |
|
|
41
|
+
| `vibedata-odcs version` | package, standard and pinned-schema versions | no |
|
|
42
|
+
|
|
43
|
+
Diagnostics are JSON on stdout. The exit code is 0 when there are no errors (warnings are allowed) and 1 otherwise. That JSON, its diagnostic `code` values and the exit codes are the public API this package versions (see `CHANGELOG.md`).
|
|
44
|
+
|
|
45
|
+
The console script is `vibedata-odcs`, prefixed so it cannot collide with another tool's `odcs` in a shared environment; `python -m vibedata.odcs` is the same command.
|
|
46
|
+
|
|
47
|
+
## The warehouse
|
|
48
|
+
|
|
49
|
+
`--catalog PATH` names the local database file (or dbt's `catalog.json` for `check --relations`).
|
|
50
|
+
Without it, the command asks the installed destination resolver. The library ships none, because it knows no platform. A caller that knows the Intent's platform passes one:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from vibedata.odcs.cli import main
|
|
54
|
+
|
|
55
|
+
main(destination_resolver=lambda repo_map: {"family": "duckdb", "path": "/sandbox.duckdb", "platform": "duckdb_local"})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
With no resolver and no `--catalog`, a warehouse command refuses with `catalog/missing` rather than passing silently.
|
|
59
|
+
|
|
60
|
+
The resolver is process-wide state: every `main()` call sets it, `None` included, so a long-lived process that calls `main()` repeatedly passes the resolver it wants on each call.
|
|
61
|
+
|
|
62
|
+
## Vendored schema
|
|
63
|
+
|
|
64
|
+
`src/vibedata/odcs/schemas/` holds the pinned ODCS JSON schema; see its README for provenance. Validation never fetches, so `check` runs offline.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# vibedata-odcs
|
|
2
|
+
|
|
3
|
+
Data-contract tooling for [Open Data Contract Standard](https://github.com/bitol-io/open-data-contract-standard) (ODCS v3.2.0) contracts at a VibeData Domain's boundary.
|
|
4
|
+
One library and one `vibedata-odcs` command, shared by the Build agent's `data-contract` skills, Domain CI's logical-model gate, and Studio. It replaces the `odcs.py` script that was copied into each of them.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install vibedata-odcs # check, generate, diff, results, version
|
|
8
|
+
pip install "vibedata-odcs[warehouse]" # adds duckdb for check --relations, reverse and profile
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Run from the Domain's repository root:
|
|
12
|
+
|
|
13
|
+
| Command | What it does | Reads a warehouse |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| `vibedata-odcs check` | schema and structure of the committed contracts, one per boundary relation, agreement with the canonical model | no |
|
|
16
|
+
| `vibedata-odcs check --relations` | adds the comparison against the built relation (or dbt's `catalog.json`) | yes |
|
|
17
|
+
| `vibedata-odcs reverse [--force]` | drafts the boundary's contracts from a built warehouse, with the open questions as findings | yes |
|
|
18
|
+
| `vibedata-odcs generate` | writes the dbt keys and the dlt `COLUMNS` hints from the contracts | no |
|
|
19
|
+
| `vibedata-odcs diff <base-ref>` | classifies each changed contract against a git base and requires the version bump it needs | no |
|
|
20
|
+
| `vibedata-odcs profile [--relation S.T]` | tests each contract's key, `required` and value-set claims against the built relation | yes |
|
|
21
|
+
| `vibedata-odcs results <run_results.json>` | maps a dbt run's test results to the contract assertions they enforce (JSON lines) | no |
|
|
22
|
+
| `vibedata-odcs version` | package, standard and pinned-schema versions | no |
|
|
23
|
+
|
|
24
|
+
Diagnostics are JSON on stdout. The exit code is 0 when there are no errors (warnings are allowed) and 1 otherwise. That JSON, its diagnostic `code` values and the exit codes are the public API this package versions (see `CHANGELOG.md`).
|
|
25
|
+
|
|
26
|
+
The console script is `vibedata-odcs`, prefixed so it cannot collide with another tool's `odcs` in a shared environment; `python -m vibedata.odcs` is the same command.
|
|
27
|
+
|
|
28
|
+
## The warehouse
|
|
29
|
+
|
|
30
|
+
`--catalog PATH` names the local database file (or dbt's `catalog.json` for `check --relations`).
|
|
31
|
+
Without it, the command asks the installed destination resolver. The library ships none, because it knows no platform. A caller that knows the Intent's platform passes one:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from vibedata.odcs.cli import main
|
|
35
|
+
|
|
36
|
+
main(destination_resolver=lambda repo_map: {"family": "duckdb", "path": "/sandbox.duckdb", "platform": "duckdb_local"})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
With no resolver and no `--catalog`, a warehouse command refuses with `catalog/missing` rather than passing silently.
|
|
40
|
+
|
|
41
|
+
The resolver is process-wide state: every `main()` call sets it, `None` included, so a long-lived process that calls `main()` repeatedly passes the resolver it wants on each call.
|
|
42
|
+
|
|
43
|
+
## Vendored schema
|
|
44
|
+
|
|
45
|
+
`src/vibedata/odcs/schemas/` holds the pinned ODCS JSON schema; see its README for provenance. Validation never fetches, so `check` runs offline.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vibedata-odcs"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "VibeData data-contract tooling: check, generate, reverse, diff, profile and results for Open Data Contract Standard (ODCS v3.2.0) contracts at a Domain's boundary. One library and one `odcs` command shared by the Build agent's skills, Domain CI and Studio."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Vibedata", email = "eng@acceleratedata.ai" }]
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
# >=4.18: the referencing-based resolver the vendored v3.2.0 schema's $defs need.
|
|
16
|
+
"jsonschema>=4.18",
|
|
17
|
+
"pyyaml>=6",
|
|
18
|
+
# >=0.13: 0.12 cannot run against the current click ("Secondary flag is not valid
|
|
19
|
+
# for non-boolean flag"); proven by the lowest-direct CI job.
|
|
20
|
+
"typer>=0.13",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
# Read only by `check --relations`, `reverse` and `profile`. Its version is the
|
|
25
|
+
# runtime's own (the managed constraints pin it), so no floor is stated here.
|
|
26
|
+
warehouse = ["duckdb"]
|
|
27
|
+
dev = ["pytest>=8.0", "duckdb"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Repository = "https://github.com/accelerate-data/vibedata-python-packages"
|
|
31
|
+
Changelog = "https://github.com/accelerate-data/vibedata-python-packages/blob/main/packages/vibedata-odcs/CHANGELOG.md"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
vibedata-odcs = "vibedata.odcs.cli:main"
|
|
35
|
+
|
|
36
|
+
# PEP 420 namespace package: `vibedata` carries no __init__.py, so this
|
|
37
|
+
# distribution contributes `vibedata.odcs` beside `vibedata.dlt.*`.
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/vibedata"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""VibeData data-contract tooling for Open Data Contract Standard (ODCS) contracts.
|
|
2
|
+
|
|
3
|
+
The command line is the public interface: `odcs <command>` (or `python -m vibedata.odcs`)
|
|
4
|
+
prints JSON diagnostics and exits 0 or 1. `vibedata.odcs.cli.main` runs it in-process and
|
|
5
|
+
accepts a destination resolver for callers that know the Intent's platform.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = _version("vibedata-odcs")
|
|
12
|
+
except PackageNotFoundError: # a source checkout run without installing the distribution
|
|
13
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Where a contract is owed: the Domain's repo map, its layers' roles, and the boundary relations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from vibedata.odcs.diagnostics import _Refused, _diag
|
|
9
|
+
|
|
10
|
+
# Relations the installed loader library writes into every dlt schema. No dlt resource declares
|
|
11
|
+
# them, so none is on the boundary, a contract on one is no orphan, and nothing is generated
|
|
12
|
+
# from it.
|
|
13
|
+
AUDIT_RELATIONS = ("audit_runs", "audit_table_loads")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _repo_root(root: str) -> Path:
|
|
17
|
+
"""The Domain root: `--root`, else the nearest directory above the working one with a map."""
|
|
18
|
+
start = Path(root).resolve() if root else Path.cwd().resolve()
|
|
19
|
+
for candidate in [start, *start.parents]:
|
|
20
|
+
if (candidate / "repo-map.json").is_file():
|
|
21
|
+
return candidate
|
|
22
|
+
raise _Refused(_diag("error", "map/missing",
|
|
23
|
+
f"no repo-map.json at or above {start}; run from the Domain's root"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _repo_map(root: Path) -> dict:
|
|
27
|
+
return json.loads((root / "repo-map.json").read_text(encoding="utf-8"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _declared(root: Path, raw: str) -> Path:
|
|
31
|
+
"""The path an artifact pattern names, up to its first placeholder."""
|
|
32
|
+
prefix = raw.split("{")[0].split("*")[0].rstrip("/")
|
|
33
|
+
return root / prefix
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def declared_relations(root: Path, repo_map: dict) -> dict[tuple[str, str], str]:
|
|
37
|
+
"""Every relation the map declares, as `(schema, table)` mapped to what declares it.
|
|
38
|
+
|
|
39
|
+
dbt models and seeds come from the layer paths. Raw relations come from each source's
|
|
40
|
+
resource docs. The loader's audit relations are written by the installed library and appear
|
|
41
|
+
in none of these: a contract on one is no orphan, and none is owed.
|
|
42
|
+
"""
|
|
43
|
+
expected: dict[tuple[str, str], str] = {}
|
|
44
|
+
for name, layer in (repo_map.get("layers") or {}).items():
|
|
45
|
+
path = root / layer["path"] if layer.get("path") else None
|
|
46
|
+
if path is None or not path.is_dir() or layer.get("built_by") != "dbt":
|
|
47
|
+
continue
|
|
48
|
+
# A dbt layer builds whatever it holds: a model from a `.sql`, a seed from a `.csv`.
|
|
49
|
+
# Which suffix belongs to which is the file's fact, not the layer's name.
|
|
50
|
+
for suffix in (".sql", ".csv"):
|
|
51
|
+
for file in sorted(path.rglob(f"*{suffix}")):
|
|
52
|
+
expected[(layer["schema"], file.stem)] = f"layer {name}"
|
|
53
|
+
for name, source in (repo_map.get("sources") or {}).items():
|
|
54
|
+
docs = source.get("docs")
|
|
55
|
+
if not docs:
|
|
56
|
+
continue
|
|
57
|
+
entries = yaml.safe_load((root / docs).read_text(encoding="utf-8")) or {}
|
|
58
|
+
for resource in sorted(entries.get("resources") or {}):
|
|
59
|
+
expected[(source["dataset"], resource)] = f"source {name}"
|
|
60
|
+
return expected
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _unknown_gold_layers(repo_map: dict) -> list[dict]:
|
|
64
|
+
"""A legacy `gold_layers` entry that names no layer declares no boundary; warn, so a stale entry never fails an existing Domain."""
|
|
65
|
+
named = repo_map.get("gold_layers")
|
|
66
|
+
if named is None:
|
|
67
|
+
return []
|
|
68
|
+
if not isinstance(named, list):
|
|
69
|
+
return [_diag("warning", "map/gold-layers",
|
|
70
|
+
"repo-map.json `gold_layers` is not a list of layer names")]
|
|
71
|
+
layers = repo_map.get("layers") or {}
|
|
72
|
+
return [
|
|
73
|
+
_diag("warning", "map/gold-layers",
|
|
74
|
+
f"repo-map.json `gold_layers` names `{name}`, which `layers` does not declare")
|
|
75
|
+
for name in named
|
|
76
|
+
if name not in layers
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _unknown_contract_layers(repo_map: dict) -> list[dict]:
|
|
81
|
+
"""A `contract_layers` entry that names no layer scopes nothing; saying so is the report."""
|
|
82
|
+
named = repo_map.get("contract_layers")
|
|
83
|
+
if named is None:
|
|
84
|
+
return []
|
|
85
|
+
if not isinstance(named, list):
|
|
86
|
+
return [_diag("error", "map/contract-layers",
|
|
87
|
+
"repo-map.json `contract_layers` is not a list of layer names")]
|
|
88
|
+
layers = repo_map.get("layers") or {}
|
|
89
|
+
return [
|
|
90
|
+
_diag("error", "map/contract-layers",
|
|
91
|
+
f"repo-map.json `contract_layers` names `{name}`, which `layers` does not declare")
|
|
92
|
+
for name in named
|
|
93
|
+
if name not in layers
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _exclusions(repo_map: dict) -> tuple[dict[tuple[str, str], str], list[dict]]:
|
|
98
|
+
"""The relations the canonical model deliberately leaves out, with the reason each states.
|
|
99
|
+
|
|
100
|
+
An entry the tool cannot read excludes nothing; saying so is the report.
|
|
101
|
+
"""
|
|
102
|
+
entries = repo_map.get("semantic_exclusions")
|
|
103
|
+
if entries is None:
|
|
104
|
+
return {}, []
|
|
105
|
+
if not isinstance(entries, list):
|
|
106
|
+
return {}, [_diag("error", "map/semantic-exclusions",
|
|
107
|
+
"repo-map.json `semantic_exclusions` is not a list of "
|
|
108
|
+
"{relation, reason} entries")]
|
|
109
|
+
excluded: dict[tuple[str, str], str] = {}
|
|
110
|
+
diags = []
|
|
111
|
+
for entry in entries:
|
|
112
|
+
relation = entry.get("relation") if isinstance(entry, dict) else None
|
|
113
|
+
reason = entry.get("reason") if isinstance(entry, dict) else None
|
|
114
|
+
if (isinstance(relation, str) and relation.count(".") == 1 and all(relation.split("."))
|
|
115
|
+
and isinstance(reason, str) and reason.strip()):
|
|
116
|
+
schema, table = relation.split(".")
|
|
117
|
+
excluded[(schema, table)] = reason.strip()
|
|
118
|
+
else:
|
|
119
|
+
diags.append(_diag("error", "map/semantic-exclusions",
|
|
120
|
+
f"repo-map.json `semantic_exclusions` entry {entry!r} needs a "
|
|
121
|
+
f"`relation` as `schema.table` and a `reason`"))
|
|
122
|
+
return excluded, diags
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
LAYER_ROLES = ("input", "internal", "boundary")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _role_for(repo_map: dict, name: str) -> str:
|
|
129
|
+
"""A layer's role: `layers.<name>.role` when valid, else derived from the legacy keys.
|
|
130
|
+
|
|
131
|
+
Derived: `built_by == "dlt"` is `input` (even when the layer is also named in `gold_layers`);
|
|
132
|
+
a name in `gold_layers` (the legacy way to declare a
|
|
133
|
+
boundary layer) is `boundary`; anything else is `internal`. An invalid explicit role falls
|
|
134
|
+
back to the derivation; `check` reports it as `map/layer-role`.
|
|
135
|
+
"""
|
|
136
|
+
layer = (repo_map.get("layers") or {}).get(name)
|
|
137
|
+
layer = layer if isinstance(layer, dict) else {}
|
|
138
|
+
if layer.get("role") in LAYER_ROLES:
|
|
139
|
+
return layer["role"]
|
|
140
|
+
if layer.get("built_by") == "dlt":
|
|
141
|
+
return "input"
|
|
142
|
+
if name in (repo_map.get("gold_layers") or []):
|
|
143
|
+
return "boundary"
|
|
144
|
+
return "internal"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _roles(repo_map: dict) -> dict[str, str]:
|
|
148
|
+
"""The role of every layer the map declares."""
|
|
149
|
+
return {name: _role_for(repo_map, name) for name in repo_map.get("layers") or {}}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _role_schemas(repo_map: dict, *roles: str) -> set[str]:
|
|
153
|
+
"""Schemas of the layers whose role is one of `roles`."""
|
|
154
|
+
layers = repo_map.get("layers") or {}
|
|
155
|
+
return {layers[name]["schema"] for name, role in _roles(repo_map).items()
|
|
156
|
+
if role in roles and "schema" in layers[name]}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _layer_role_diags(repo_map: dict) -> list[dict]:
|
|
160
|
+
"""A `role` outside `input`, `internal` and `boundary` is reported, not guessed at."""
|
|
161
|
+
return [
|
|
162
|
+
_diag("error", "map/layer-role",
|
|
163
|
+
f"repo-map.json layers.{name}.role must be input, internal or boundary")
|
|
164
|
+
for name, layer in (repo_map.get("layers") or {}).items()
|
|
165
|
+
if isinstance(layer, dict) and "role" in layer and layer["role"] not in LAYER_ROLES
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def boundary_relations(repo_map: dict,
|
|
170
|
+
declared: dict[tuple[str, str], str]) -> dict[tuple[str, str], str]:
|
|
171
|
+
"""The relations a contract is owed for: the inputs and outputs of the Domain.
|
|
172
|
+
|
|
173
|
+
By default, every relation a dlt source loads (a consumer of the load sees its shape first)
|
|
174
|
+
and every relation a layer with role `input` or `boundary` builds (what a consumer outside
|
|
175
|
+
the repo reads). Layers with role `internal` are guarded by their dbt tests and owe none.
|
|
176
|
+
`contract_layers`, when the map declares it, replaces that default with the layers it names, a dlt layer
|
|
177
|
+
contributing the resources its sources land in that schema.
|
|
178
|
+
"""
|
|
179
|
+
layers = repo_map.get("layers") or {}
|
|
180
|
+
named = repo_map.get("contract_layers")
|
|
181
|
+
if isinstance(named, list):
|
|
182
|
+
schemas = {layers[name]["schema"] for name in named if name in layers}
|
|
183
|
+
return {relation: by for relation, by in declared.items() if relation[0] in schemas}
|
|
184
|
+
owed = _role_schemas(repo_map, "input", "boundary")
|
|
185
|
+
# Only an explicit `role: internal` stops a source from owing a contract; a layer whose role
|
|
186
|
+
# is merely derived as internal still owes one for the sources that land in it.
|
|
187
|
+
internal = {layer["schema"] for layer in layers.values()
|
|
188
|
+
if isinstance(layer, dict) and layer.get("role") == "internal" and "schema" in layer}
|
|
189
|
+
return {
|
|
190
|
+
relation: by
|
|
191
|
+
for relation, by in declared.items()
|
|
192
|
+
if (by.startswith("source ") and relation[0] not in internal)
|
|
193
|
+
or (relation[0] in owed and by.startswith("layer "))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _boundary_rank(relation: tuple[str, str], repo_map: dict,
|
|
198
|
+
declared: dict[tuple[str, str], str]) -> int:
|
|
199
|
+
"""Boundary first, then inputs, then anything else: the order a reviewer triages in."""
|
|
200
|
+
if relation[0] in _role_schemas(repo_map, "boundary"):
|
|
201
|
+
return 0
|
|
202
|
+
if (declared.get(relation) or "").startswith("source "):
|
|
203
|
+
return 1
|
|
204
|
+
return 2
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _boundary_schemas(repo_map: dict, expected: dict[tuple[str, str], str]) -> set[str]:
|
|
208
|
+
"""Schemas holding boundary relations: the layers with role `boundary`, where they are built.
|
|
209
|
+
|
|
210
|
+
The map is the only source. Another Domain names its layers differently, so a layer name is
|
|
211
|
+
something this tool cannot know; guessing one — by exclusion, or by the shape of the
|
|
212
|
+
directory — would gate relations the Domain never called boundary and pass over the ones it did.
|
|
213
|
+
An undeclared set is therefore empty and names nothing, and `check` warns about the gap
|
|
214
|
+
instead of inventing a boundary set to fill it.
|
|
215
|
+
"""
|
|
216
|
+
built = {schema for schema, _ in expected}
|
|
217
|
+
return _role_schemas(repo_map, "boundary") & built
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _layer_for(repo_map: dict, schema: str) -> dict | None:
|
|
221
|
+
"""The layer that declares a schema; the map is the only place a schema is named."""
|
|
222
|
+
for layer in (repo_map.get("layers") or {}).values():
|
|
223
|
+
if layer.get("schema") == schema:
|
|
224
|
+
return layer
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_dlt(relation: tuple[str, str], repo_map: dict) -> bool:
|
|
229
|
+
"""Whether a dlt pipeline builds the relation: the only place the loader adds `_dlt_*`."""
|
|
230
|
+
layer = _layer_for(repo_map, relation[0])
|
|
231
|
+
return layer is not None and layer.get("built_by") == "dlt"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _is_dbt(relation: tuple[str, str], repo_map: dict) -> bool:
|
|
235
|
+
"""Whether a dbt layer builds the relation, as opposed to a dlt pipeline or the loader."""
|
|
236
|
+
layer = _layer_for(repo_map, relation[0])
|
|
237
|
+
return layer is not None and layer.get("built_by") == "dbt"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _built_models(root: Path, layer: dict) -> set[str] | None:
|
|
241
|
+
"""The models dbt built successfully, from the `target/run_results.json` of the project
|
|
242
|
+
holding the layer's path; None when there is no such file (no evidence either way)."""
|
|
243
|
+
folder = (root / layer["path"]).resolve()
|
|
244
|
+
for project in (folder, *folder.parents):
|
|
245
|
+
if (project / "dbt_project.yml").is_file():
|
|
246
|
+
break
|
|
247
|
+
if project == root.resolve():
|
|
248
|
+
return None
|
|
249
|
+
else:
|
|
250
|
+
return None
|
|
251
|
+
path = project / "target" / "run_results.json"
|
|
252
|
+
if not path.is_file():
|
|
253
|
+
return None
|
|
254
|
+
try:
|
|
255
|
+
found = json.loads(path.read_text(encoding="utf-8")).get("results") or []
|
|
256
|
+
except (OSError, ValueError, AttributeError):
|
|
257
|
+
return None
|
|
258
|
+
return {str(result.get("unique_id", "")).split(".")[-1] for result in found
|
|
259
|
+
if isinstance(result, dict) and result.get("status") in ("success", "pass")
|
|
260
|
+
and str(result.get("unique_id", "")).split(".")[0] in ("model", "seed", "snapshot")}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _active_unbuilt(root: Path, repo_map: dict, relation: tuple[str, str], path: Path,
|
|
264
|
+
doc: dict, excluded: dict) -> list[dict]:
|
|
265
|
+
"""An `active` contract whose dbt relation the last run did not build: still a proposal."""
|
|
266
|
+
if doc.get("status") != "active" or relation in excluded or not _is_dbt(relation, repo_map):
|
|
267
|
+
return []
|
|
268
|
+
built = _built_models(root, _layer_for(repo_map, relation[0]))
|
|
269
|
+
if built is None or relation[1] in built:
|
|
270
|
+
return []
|
|
271
|
+
return [_diag("warning", "contract/active-unbuilt",
|
|
272
|
+
f"{relation[0]}.{relation[1]}: status is active but dbt has not built "
|
|
273
|
+
f"{relation[0]}.{relation[1]}; mark it proposed until the table exists", path)]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _is_loader_column(relation: tuple[str, str], name: str, repo_map: dict) -> bool:
|
|
277
|
+
"""A `_dlt_*` column dlt adds to a relation it loaded.
|
|
278
|
+
|
|
279
|
+
A dbt model that selects the column propagates it deliberately, and its enforced contract
|
|
280
|
+
must declare every output column, so only a raw relation excludes the loader's own.
|
|
281
|
+
"""
|
|
282
|
+
return name.startswith("_dlt") and not _is_dbt(relation, repo_map)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _is_audit(relation: tuple[str, str], repo_map: dict) -> bool:
|
|
286
|
+
"""A relation the installed loader writes into a dlt layer's schema, not a dlt resource."""
|
|
287
|
+
layer = _layer_for(repo_map, relation[0])
|
|
288
|
+
return (
|
|
289
|
+
layer is not None
|
|
290
|
+
and layer.get("built_by") == "dlt"
|
|
291
|
+
and relation[1] in AUDIT_RELATIONS
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _platform(repo_map: dict) -> str:
|
|
296
|
+
"""The platform the map declares. One Domain runs on one, so the map is the only source."""
|
|
297
|
+
return str(repo_map.get("data_platform") or "")
|