dbt-data-contracts 0.1.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.
- dbt_contracts/__init__.py +3 -0
- dbt_contracts/cli/__init__.py +5 -0
- dbt_contracts/cli/app.py +56 -0
- dbt_contracts/cli/check.py +112 -0
- dbt_contracts/cli/consumer.py +107 -0
- dbt_contracts/cli/ingest.py +64 -0
- dbt_contracts/cli/publish.py +97 -0
- dbt_contracts/cli/registry.py +62 -0
- dbt_contracts/compatibility/__init__.py +11 -0
- dbt_contracts/compatibility/engine.py +119 -0
- dbt_contracts/compatibility/rules/columns.py +79 -0
- dbt_contracts/compatibility/rules/consumers.py +76 -0
- dbt_contracts/compatibility/rules/metadata.py +29 -0
- dbt_contracts/compatibility/rules/nullability.py +45 -0
- dbt_contracts/compatibility/types.py +102 -0
- dbt_contracts/consumers/__init__.py +9 -0
- dbt_contracts/consumers/parser.py +112 -0
- dbt_contracts/consumers/resolver.py +20 -0
- dbt_contracts/dbt/__init__.py +11 -0
- dbt_contracts/dbt/ingestion.py +170 -0
- dbt_contracts/dbt/manifest.py +42 -0
- dbt_contracts/domain/__init__.py +51 -0
- dbt_contracts/domain/compatibility.py +66 -0
- dbt_contracts/domain/consumers.py +41 -0
- dbt_contracts/domain/contracts.py +49 -0
- dbt_contracts/domain/provenance.py +61 -0
- dbt_contracts/domain/versions.py +140 -0
- dbt_contracts/exceptions.py +37 -0
- dbt_contracts/registry/__init__.py +9 -0
- dbt_contracts/registry/protocol.py +61 -0
- dbt_contracts/registry/schema.py +78 -0
- dbt_contracts/registry/sqlite.py +229 -0
- dbt_contracts/reporting/__init__.py +9 -0
- dbt_contracts/reporting/console.py +131 -0
- dbt_contracts/reporting/json.py +16 -0
- dbt_data_contracts-0.1.0.dist-info/METADATA +210 -0
- dbt_data_contracts-0.1.0.dist-info/RECORD +40 -0
- dbt_data_contracts-0.1.0.dist-info/WHEEL +4 -0
- dbt_data_contracts-0.1.0.dist-info/entry_points.txt +2 -0
- dbt_data_contracts-0.1.0.dist-info/licenses/LICENSE +21 -0
dbt_contracts/cli/app.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Main Typer CLI entry point for dbt_contracts."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from dbt_contracts import __version__
|
|
6
|
+
from dbt_contracts.cli.check import check_command
|
|
7
|
+
from dbt_contracts.cli.consumer import app as consumer_app
|
|
8
|
+
from dbt_contracts.cli.ingest import ingest_command
|
|
9
|
+
from dbt_contracts.cli.publish import publish_command
|
|
10
|
+
from dbt_contracts.cli.registry import app as registry_app
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="dbt-contracts",
|
|
14
|
+
help="Data contract control plane for multi-repository dbt ecosystems.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def version_callback(value: bool) -> None:
|
|
20
|
+
if value:
|
|
21
|
+
typer.echo(f"dbt-contracts version {__version__}")
|
|
22
|
+
raise typer.Exit()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback()
|
|
26
|
+
def main(
|
|
27
|
+
version: bool = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--version",
|
|
30
|
+
"-V",
|
|
31
|
+
callback=version_callback,
|
|
32
|
+
is_eager=True,
|
|
33
|
+
help="Show version and exit.",
|
|
34
|
+
),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""dbt-contracts: Data contract control plane for dbt."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Register top-level commands
|
|
40
|
+
app.command(name="ingest", help="Ingest and inspect public models from a dbt manifest.")(
|
|
41
|
+
ingest_command
|
|
42
|
+
)
|
|
43
|
+
app.command(name="publish", help="Publish public dbt models as versioned contracts.")(
|
|
44
|
+
publish_command
|
|
45
|
+
)
|
|
46
|
+
app.command(name="check", help="Check proposed contract changes against registry and consumers.")(
|
|
47
|
+
check_command
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Register sub-apps
|
|
51
|
+
app.add_typer(consumer_app, name="consumer", help="Manage consumer contract declarations.")
|
|
52
|
+
app.add_typer(registry_app, name="registry", help="Query the contract registry.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
app()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""CLI command for CI/PR contract compatibility gating."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from dbt_contracts.compatibility.engine import CompatibilityEngine
|
|
9
|
+
from dbt_contracts.dbt.ingestion import DbtManifestIngestor
|
|
10
|
+
from dbt_contracts.domain.compatibility import CompatibilityResult
|
|
11
|
+
from dbt_contracts.exceptions import DbtContractsError
|
|
12
|
+
from dbt_contracts.registry.sqlite import SqliteContractRegistry
|
|
13
|
+
from dbt_contracts.reporting.console import ConsoleReporter
|
|
14
|
+
from dbt_contracts.reporting.json import render_json
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
help="Check proposed contract changes against published contracts and registered consumers."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback(invoke_without_command=True)
|
|
22
|
+
def check_command(
|
|
23
|
+
manifest: Annotated[
|
|
24
|
+
Path,
|
|
25
|
+
typer.Option(
|
|
26
|
+
"--manifest",
|
|
27
|
+
"-m",
|
|
28
|
+
help="Path to dbt manifest.json file representing proposed changes.",
|
|
29
|
+
exists=True,
|
|
30
|
+
file_okay=True,
|
|
31
|
+
dir_okay=False,
|
|
32
|
+
readable=True,
|
|
33
|
+
),
|
|
34
|
+
] = Path("target/manifest.json"),
|
|
35
|
+
registry_path: Annotated[
|
|
36
|
+
Path,
|
|
37
|
+
typer.Option(
|
|
38
|
+
"--registry",
|
|
39
|
+
"-r",
|
|
40
|
+
help="Path to SQLite contract registry database file.",
|
|
41
|
+
),
|
|
42
|
+
] = Path(".contracts.db"),
|
|
43
|
+
proposed_version: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--proposed-version",
|
|
47
|
+
"-v",
|
|
48
|
+
help="Proposed contract version to evaluate (e.g. 1.1.0 or 2.0.0).",
|
|
49
|
+
),
|
|
50
|
+
] = None,
|
|
51
|
+
product: Annotated[
|
|
52
|
+
str | None,
|
|
53
|
+
typer.Option("--product", "-p", help="Override data product name."),
|
|
54
|
+
] = None,
|
|
55
|
+
all_models: Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option("--all", "-a", help="Check all models, not just public models."),
|
|
58
|
+
] = False,
|
|
59
|
+
format_output: Annotated[
|
|
60
|
+
str,
|
|
61
|
+
typer.Option("--format", "-f", help="Output format ('console' or 'json')."),
|
|
62
|
+
] = "console",
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Evaluate proposed contracts against registry for breaking changes and consumer impact."""
|
|
65
|
+
ingestor = DbtManifestIngestor(
|
|
66
|
+
default_version=proposed_version or "1.0.0",
|
|
67
|
+
include_all_models=all_models,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
proposed_contracts = ingestor.parse_manifest_file(
|
|
72
|
+
manifest_path=manifest,
|
|
73
|
+
version_override=proposed_version,
|
|
74
|
+
product_override=product,
|
|
75
|
+
)
|
|
76
|
+
except DbtContractsError as e:
|
|
77
|
+
typer.secho(f"Error parsing manifest: {e}", fg=typer.colors.RED, err=True)
|
|
78
|
+
raise typer.Exit(code=2) from e
|
|
79
|
+
|
|
80
|
+
if not proposed_contracts:
|
|
81
|
+
typer.secho("No public contracts found to evaluate.", fg=typer.colors.YELLOW)
|
|
82
|
+
raise typer.Exit(code=0)
|
|
83
|
+
|
|
84
|
+
registry = SqliteContractRegistry(db_path=registry_path)
|
|
85
|
+
engine = CompatibilityEngine()
|
|
86
|
+
|
|
87
|
+
results: list[CompatibilityResult] = []
|
|
88
|
+
has_failure = False
|
|
89
|
+
|
|
90
|
+
for proposed in proposed_contracts:
|
|
91
|
+
current = registry.get_latest_contract(proposed.product, proposed.model)
|
|
92
|
+
consumers = registry.list_consumers(proposed.product, proposed.model)
|
|
93
|
+
|
|
94
|
+
result = engine.compare_contracts(
|
|
95
|
+
current_contract=current,
|
|
96
|
+
proposed_contract=proposed,
|
|
97
|
+
consumers=consumers,
|
|
98
|
+
)
|
|
99
|
+
results.append(result)
|
|
100
|
+
if not result.is_compatible:
|
|
101
|
+
has_failure = True
|
|
102
|
+
|
|
103
|
+
if format_output.lower() == "json":
|
|
104
|
+
typer.echo(render_json(results))
|
|
105
|
+
else:
|
|
106
|
+
reporter = ConsoleReporter()
|
|
107
|
+
reporter.render_compatibility_results(results)
|
|
108
|
+
|
|
109
|
+
if has_failure:
|
|
110
|
+
raise typer.Exit(code=1)
|
|
111
|
+
|
|
112
|
+
raise typer.Exit(code=0)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CLI subcommands for managing consumer expectations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from dbt_contracts.consumers.parser import ConsumerDeclarationParser
|
|
9
|
+
from dbt_contracts.exceptions import ConsumerDeclarationError
|
|
10
|
+
from dbt_contracts.registry.sqlite import SqliteContractRegistry
|
|
11
|
+
from dbt_contracts.reporting.console import ConsoleReporter
|
|
12
|
+
from dbt_contracts.reporting.json import render_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Manage downstream consumer contract declarations.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("register")
|
|
18
|
+
def register_consumer_command(
|
|
19
|
+
file_path: Annotated[
|
|
20
|
+
Path,
|
|
21
|
+
typer.Option(
|
|
22
|
+
"--file",
|
|
23
|
+
"-f",
|
|
24
|
+
help="Path to data-contract-consumers.yml declaration file.",
|
|
25
|
+
exists=True,
|
|
26
|
+
file_okay=True,
|
|
27
|
+
dir_okay=False,
|
|
28
|
+
readable=True,
|
|
29
|
+
),
|
|
30
|
+
] = Path("data-contract-consumers.yml"),
|
|
31
|
+
registry_path: Annotated[
|
|
32
|
+
Path,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--registry",
|
|
35
|
+
"-r",
|
|
36
|
+
help="Path to SQLite contract registry database file.",
|
|
37
|
+
),
|
|
38
|
+
] = Path(".contracts.db"),
|
|
39
|
+
format_output: Annotated[
|
|
40
|
+
str,
|
|
41
|
+
typer.Option("--format", help="Output format ('console' or 'json')."),
|
|
42
|
+
] = "console",
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Parse a consumer declaration YAML file and register expectations in the registry."""
|
|
45
|
+
parser = ConsumerDeclarationParser()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
expectations = parser.parse_file(file_path)
|
|
49
|
+
except ConsumerDeclarationError as e:
|
|
50
|
+
typer.secho(f"Error parsing consumer declarations: {e}", fg=typer.colors.RED, err=True)
|
|
51
|
+
raise typer.Exit(code=2) from e
|
|
52
|
+
|
|
53
|
+
registry = SqliteContractRegistry(db_path=registry_path)
|
|
54
|
+
for exp in expectations:
|
|
55
|
+
registry.register_consumer(exp)
|
|
56
|
+
|
|
57
|
+
if format_output.lower() == "json":
|
|
58
|
+
typer.echo(render_json(expectations))
|
|
59
|
+
else:
|
|
60
|
+
reporter = ConsoleReporter()
|
|
61
|
+
typer.secho(
|
|
62
|
+
f"Successfully registered {len(expectations)} expectation(s) in {registry_path}:",
|
|
63
|
+
fg=typer.colors.GREEN,
|
|
64
|
+
)
|
|
65
|
+
reporter.render_consumers_list(expectations)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("list")
|
|
69
|
+
def list_consumers_command(
|
|
70
|
+
registry_path: Annotated[
|
|
71
|
+
Path,
|
|
72
|
+
typer.Option(
|
|
73
|
+
"--registry",
|
|
74
|
+
"-r",
|
|
75
|
+
help="Path to SQLite contract registry database file.",
|
|
76
|
+
),
|
|
77
|
+
] = Path(".contracts.db"),
|
|
78
|
+
product: Annotated[
|
|
79
|
+
str | None,
|
|
80
|
+
typer.Option("--product", "-p", help="Filter by target product name."),
|
|
81
|
+
] = None,
|
|
82
|
+
model: Annotated[
|
|
83
|
+
str | None,
|
|
84
|
+
typer.Option("--model", "-m", help="Filter by target model name."),
|
|
85
|
+
] = None,
|
|
86
|
+
format_output: Annotated[
|
|
87
|
+
str,
|
|
88
|
+
typer.Option("--format", help="Output format ('console' or 'json')."),
|
|
89
|
+
] = "console",
|
|
90
|
+
) -> None:
|
|
91
|
+
"""List registered consumer expectations."""
|
|
92
|
+
registry = SqliteContractRegistry(db_path=registry_path)
|
|
93
|
+
|
|
94
|
+
if product and model:
|
|
95
|
+
consumers = registry.list_consumers(product, model)
|
|
96
|
+
else:
|
|
97
|
+
consumers = registry.list_all_consumers()
|
|
98
|
+
if product:
|
|
99
|
+
consumers = [c for c in consumers if c.product == product]
|
|
100
|
+
if model:
|
|
101
|
+
consumers = [c for c in consumers if c.model == model]
|
|
102
|
+
|
|
103
|
+
if format_output.lower() == "json":
|
|
104
|
+
typer.echo(render_json(consumers))
|
|
105
|
+
else:
|
|
106
|
+
reporter = ConsoleReporter()
|
|
107
|
+
reporter.render_consumers_list(consumers)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""CLI command for ingesting and inspecting dbt manifests."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from dbt_contracts.dbt.ingestion import DbtManifestIngestor
|
|
9
|
+
from dbt_contracts.exceptions import ManifestParseError
|
|
10
|
+
from dbt_contracts.reporting.console import ConsoleReporter
|
|
11
|
+
from dbt_contracts.reporting.json import render_json
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Ingest and inspect dbt artifacts.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.callback(invoke_without_command=True)
|
|
17
|
+
def ingest_command(
|
|
18
|
+
manifest: Annotated[
|
|
19
|
+
Path,
|
|
20
|
+
typer.Option(
|
|
21
|
+
"--manifest",
|
|
22
|
+
"-m",
|
|
23
|
+
help="Path to dbt manifest.json file.",
|
|
24
|
+
exists=True,
|
|
25
|
+
file_okay=True,
|
|
26
|
+
dir_okay=False,
|
|
27
|
+
readable=True,
|
|
28
|
+
),
|
|
29
|
+
] = Path("target/manifest.json"),
|
|
30
|
+
version: Annotated[
|
|
31
|
+
str | None,
|
|
32
|
+
typer.Option("--version", "-v", help="Override contract version for discovered models."),
|
|
33
|
+
] = None,
|
|
34
|
+
product: Annotated[
|
|
35
|
+
str | None,
|
|
36
|
+
typer.Option("--product", "-p", help="Override data product name for discovered models."),
|
|
37
|
+
] = None,
|
|
38
|
+
all_models: Annotated[
|
|
39
|
+
bool,
|
|
40
|
+
typer.Option("--all", "-a", help="Include all models, not just public models."),
|
|
41
|
+
] = False,
|
|
42
|
+
format_output: Annotated[
|
|
43
|
+
str,
|
|
44
|
+
typer.Option("--format", "-f", help="Output format ('console' or 'json')."),
|
|
45
|
+
] = "console",
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Ingest a dbt manifest.json and print discovered public contracts."""
|
|
48
|
+
ingestor = DbtManifestIngestor(include_all_models=all_models)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
contracts = ingestor.parse_manifest_file(
|
|
52
|
+
manifest_path=manifest,
|
|
53
|
+
version_override=version,
|
|
54
|
+
product_override=product,
|
|
55
|
+
)
|
|
56
|
+
except ManifestParseError as e:
|
|
57
|
+
typer.secho(f"Error parsing manifest: {e}", fg=typer.colors.RED, err=True)
|
|
58
|
+
raise typer.Exit(code=2) from e
|
|
59
|
+
|
|
60
|
+
if format_output.lower() == "json":
|
|
61
|
+
typer.echo(render_json(contracts))
|
|
62
|
+
else:
|
|
63
|
+
reporter = ConsoleReporter()
|
|
64
|
+
reporter.render_contracts_list(contracts)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""CLI command for publishing contracts to the registry."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from dbt_contracts.dbt.ingestion import DbtManifestIngestor
|
|
9
|
+
from dbt_contracts.exceptions import ContractAlreadyPublishedError, DbtContractsError
|
|
10
|
+
from dbt_contracts.registry.sqlite import SqliteContractRegistry
|
|
11
|
+
from dbt_contracts.reporting.console import ConsoleReporter
|
|
12
|
+
from dbt_contracts.reporting.json import render_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Publish contracts to the registry.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.callback(invoke_without_command=True)
|
|
18
|
+
def publish_command(
|
|
19
|
+
manifest: Annotated[
|
|
20
|
+
Path,
|
|
21
|
+
typer.Option(
|
|
22
|
+
"--manifest",
|
|
23
|
+
"-m",
|
|
24
|
+
help="Path to dbt manifest.json file.",
|
|
25
|
+
exists=True,
|
|
26
|
+
file_okay=True,
|
|
27
|
+
dir_okay=False,
|
|
28
|
+
readable=True,
|
|
29
|
+
),
|
|
30
|
+
] = Path("target/manifest.json"),
|
|
31
|
+
registry_path: Annotated[
|
|
32
|
+
Path,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--registry",
|
|
35
|
+
"-r",
|
|
36
|
+
help="Path to SQLite contract registry database file.",
|
|
37
|
+
),
|
|
38
|
+
] = Path(".contracts.db"),
|
|
39
|
+
version: Annotated[
|
|
40
|
+
str | None,
|
|
41
|
+
typer.Option("--version", "-v", help="Contract version to publish (e.g. 1.0.0)."),
|
|
42
|
+
] = None,
|
|
43
|
+
product: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option("--product", "-p", help="Data product name override."),
|
|
46
|
+
] = None,
|
|
47
|
+
all_models: Annotated[
|
|
48
|
+
bool,
|
|
49
|
+
typer.Option("--all", "-a", help="Publish all models, not just public models."),
|
|
50
|
+
] = False,
|
|
51
|
+
format_output: Annotated[
|
|
52
|
+
str,
|
|
53
|
+
typer.Option("--format", "-f", help="Output format ('console' or 'json')."),
|
|
54
|
+
] = "console",
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Ingest dbt manifest and publish contracts to the registry."""
|
|
57
|
+
ingestor = DbtManifestIngestor(
|
|
58
|
+
default_version=version or "1.0.0", include_all_models=all_models
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
contracts = ingestor.parse_manifest_file(
|
|
63
|
+
manifest_path=manifest,
|
|
64
|
+
version_override=version,
|
|
65
|
+
product_override=product,
|
|
66
|
+
)
|
|
67
|
+
except DbtContractsError as e:
|
|
68
|
+
typer.secho(f"Error parsing manifest: {e}", fg=typer.colors.RED, err=True)
|
|
69
|
+
raise typer.Exit(code=2) from e
|
|
70
|
+
|
|
71
|
+
if not contracts:
|
|
72
|
+
typer.secho("No public contracts found to publish.", fg=typer.colors.YELLOW)
|
|
73
|
+
raise typer.Exit(code=0)
|
|
74
|
+
|
|
75
|
+
registry = SqliteContractRegistry(db_path=registry_path)
|
|
76
|
+
|
|
77
|
+
published_contracts = []
|
|
78
|
+
for contract in contracts:
|
|
79
|
+
try:
|
|
80
|
+
registry.publish_contract(contract)
|
|
81
|
+
published_contracts.append(contract)
|
|
82
|
+
except ContractAlreadyPublishedError as e:
|
|
83
|
+
typer.secho(f"Publication failed: {e}", fg=typer.colors.RED, err=True)
|
|
84
|
+
raise typer.Exit(code=1) from e
|
|
85
|
+
except Exception as e:
|
|
86
|
+
typer.secho(f"Registry error: {e}", fg=typer.colors.RED, err=True)
|
|
87
|
+
raise typer.Exit(code=2) from e
|
|
88
|
+
|
|
89
|
+
if format_output.lower() == "json":
|
|
90
|
+
typer.echo(render_json(published_contracts))
|
|
91
|
+
else:
|
|
92
|
+
reporter = ConsoleReporter()
|
|
93
|
+
typer.secho(
|
|
94
|
+
f"Successfully published {len(published_contracts)} contract(s) to {registry_path}:",
|
|
95
|
+
fg=typer.colors.GREEN,
|
|
96
|
+
)
|
|
97
|
+
reporter.render_contracts_list(published_contracts)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CLI subcommands for querying the contract registry."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from dbt_contracts.registry.sqlite import SqliteContractRegistry
|
|
9
|
+
from dbt_contracts.reporting.console import ConsoleReporter
|
|
10
|
+
from dbt_contracts.reporting.json import render_json
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Inspect published contracts in the registry.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_contracts_command(
|
|
17
|
+
registry_path: Annotated[
|
|
18
|
+
Path,
|
|
19
|
+
typer.Option(
|
|
20
|
+
"--registry",
|
|
21
|
+
"-r",
|
|
22
|
+
help="Path to SQLite contract registry database file.",
|
|
23
|
+
),
|
|
24
|
+
] = Path(".contracts.db"),
|
|
25
|
+
product: Annotated[
|
|
26
|
+
str | None,
|
|
27
|
+
typer.Option("--product", "-p", help="Filter by product name."),
|
|
28
|
+
] = None,
|
|
29
|
+
model: Annotated[
|
|
30
|
+
str | None,
|
|
31
|
+
typer.Option("--model", "-m", help="Filter by model name."),
|
|
32
|
+
] = None,
|
|
33
|
+
all_versions: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
typer.Option("--all-versions", "-a", help="List all historical versions, not just latest."),
|
|
36
|
+
] = False,
|
|
37
|
+
format_output: Annotated[
|
|
38
|
+
str,
|
|
39
|
+
typer.Option("--format", help="Output format ('console' or 'json')."),
|
|
40
|
+
] = "console",
|
|
41
|
+
) -> None:
|
|
42
|
+
"""List contracts published in the registry."""
|
|
43
|
+
registry = SqliteContractRegistry(db_path=registry_path)
|
|
44
|
+
|
|
45
|
+
if product and model:
|
|
46
|
+
if all_versions:
|
|
47
|
+
contracts = registry.list_contract_versions(product, model)
|
|
48
|
+
else:
|
|
49
|
+
latest = registry.get_latest_contract(product, model)
|
|
50
|
+
contracts = [latest] if latest else []
|
|
51
|
+
else:
|
|
52
|
+
contracts = registry.list_all_contracts()
|
|
53
|
+
if product:
|
|
54
|
+
contracts = [c for c in contracts if c.product == product]
|
|
55
|
+
if model:
|
|
56
|
+
contracts = [c for c in contracts if c.model == model]
|
|
57
|
+
|
|
58
|
+
if format_output.lower() == "json":
|
|
59
|
+
typer.echo(render_json(contracts))
|
|
60
|
+
else:
|
|
61
|
+
reporter = ConsoleReporter()
|
|
62
|
+
reporter.render_contracts_list(contracts)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""SemVer compatibility engine and rules."""
|
|
2
|
+
|
|
3
|
+
from dbt_contracts.compatibility.engine import CompatibilityEngine
|
|
4
|
+
from dbt_contracts.compatibility.types import TypeFamily, are_types_compatible, normalize_type_name
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CompatibilityEngine",
|
|
8
|
+
"TypeFamily",
|
|
9
|
+
"are_types_compatible",
|
|
10
|
+
"normalize_type_name",
|
|
11
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Compatibility engine orchestrating contract diffing and SemVer enforcement."""
|
|
2
|
+
|
|
3
|
+
from dbt_contracts.compatibility.rules.columns import evaluate_column_changes
|
|
4
|
+
from dbt_contracts.compatibility.rules.consumers import evaluate_consumer_impact
|
|
5
|
+
from dbt_contracts.compatibility.rules.metadata import evaluate_metadata_changes
|
|
6
|
+
from dbt_contracts.compatibility.rules.nullability import evaluate_nullability_changes
|
|
7
|
+
from dbt_contracts.domain.compatibility import (
|
|
8
|
+
BreakingChange,
|
|
9
|
+
CompatibilityClassification,
|
|
10
|
+
CompatibilityResult,
|
|
11
|
+
)
|
|
12
|
+
from dbt_contracts.domain.consumers import ConsumerExpectation
|
|
13
|
+
from dbt_contracts.domain.contracts import PublishedContract
|
|
14
|
+
from dbt_contracts.domain.versions import validate_version_bump
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CompatibilityEngine:
|
|
18
|
+
"""Evaluates compatibility between contract versions and registered consumers."""
|
|
19
|
+
|
|
20
|
+
def compare_contracts(
|
|
21
|
+
self,
|
|
22
|
+
current_contract: PublishedContract | None,
|
|
23
|
+
proposed_contract: PublishedContract,
|
|
24
|
+
consumers: list[ConsumerExpectation] | None = None,
|
|
25
|
+
) -> CompatibilityResult:
|
|
26
|
+
"""Compare a current contract against a proposed contract and evaluate consumer impact."""
|
|
27
|
+
consumers = consumers or []
|
|
28
|
+
breaking_changes: list[BreakingChange] = []
|
|
29
|
+
reasons: list[str] = []
|
|
30
|
+
warnings: list[str] = []
|
|
31
|
+
|
|
32
|
+
# Case 1: Brand new contract publication (no previous contract)
|
|
33
|
+
if current_contract is None:
|
|
34
|
+
reasons.append("Initial contract publication.")
|
|
35
|
+
# Evaluate against any pre-registered consumers
|
|
36
|
+
affected, consumer_warnings = evaluate_consumer_impact(
|
|
37
|
+
proposed_contract, consumers, breaking_changes
|
|
38
|
+
)
|
|
39
|
+
warnings.extend(consumer_warnings)
|
|
40
|
+
|
|
41
|
+
return CompatibilityResult(
|
|
42
|
+
product=proposed_contract.product,
|
|
43
|
+
model=proposed_contract.model,
|
|
44
|
+
current_version=None,
|
|
45
|
+
proposed_version=proposed_contract.version,
|
|
46
|
+
classification=CompatibilityClassification.PATCH,
|
|
47
|
+
is_valid_bump=True,
|
|
48
|
+
breaking_changes=[],
|
|
49
|
+
affected_consumers=affected,
|
|
50
|
+
reasons=reasons,
|
|
51
|
+
warnings=warnings,
|
|
52
|
+
suggested_min_version=proposed_contract.version,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Case 2: Comparing against existing contract version
|
|
56
|
+
# 1. Column changes
|
|
57
|
+
col_class, col_breaking, col_reasons = evaluate_column_changes(
|
|
58
|
+
current_contract, proposed_contract
|
|
59
|
+
)
|
|
60
|
+
breaking_changes.extend(col_breaking)
|
|
61
|
+
reasons.extend(col_reasons)
|
|
62
|
+
|
|
63
|
+
# 2. Nullability changes
|
|
64
|
+
null_class, null_breaking, null_reasons = evaluate_nullability_changes(
|
|
65
|
+
current_contract, proposed_contract
|
|
66
|
+
)
|
|
67
|
+
breaking_changes.extend(null_breaking)
|
|
68
|
+
reasons.extend(null_reasons)
|
|
69
|
+
|
|
70
|
+
# 3. Metadata changes
|
|
71
|
+
_, meta_reasons = evaluate_metadata_changes(current_contract, proposed_contract)
|
|
72
|
+
reasons.extend(meta_reasons)
|
|
73
|
+
|
|
74
|
+
# Determine raw classification based on severity rank
|
|
75
|
+
max_rank = max(
|
|
76
|
+
col_class.severity_rank,
|
|
77
|
+
null_class.severity_rank,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
rank_to_class = {
|
|
81
|
+
1: CompatibilityClassification.PATCH,
|
|
82
|
+
2: CompatibilityClassification.MINOR,
|
|
83
|
+
3: CompatibilityClassification.MAJOR,
|
|
84
|
+
4: CompatibilityClassification.UNKNOWN,
|
|
85
|
+
}
|
|
86
|
+
classification = rank_to_class.get(max_rank, CompatibilityClassification.MAJOR)
|
|
87
|
+
|
|
88
|
+
# 4. Consumer impact evaluation
|
|
89
|
+
affected_consumers, consumer_warnings = evaluate_consumer_impact(
|
|
90
|
+
proposed_contract, consumers, breaking_changes
|
|
91
|
+
)
|
|
92
|
+
warnings.extend(consumer_warnings)
|
|
93
|
+
|
|
94
|
+
if affected_consumers:
|
|
95
|
+
classification = CompatibilityClassification.MAJOR
|
|
96
|
+
reasons.append(
|
|
97
|
+
f"{len(affected_consumers)} registered consumer expectation(s) affected."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# 5. SemVer version bump validation
|
|
101
|
+
is_valid_bump, suggested_min_v = validate_version_bump(
|
|
102
|
+
current_contract.version,
|
|
103
|
+
proposed_contract.version,
|
|
104
|
+
classification,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return CompatibilityResult(
|
|
108
|
+
product=proposed_contract.product,
|
|
109
|
+
model=proposed_contract.model,
|
|
110
|
+
current_version=current_contract.version,
|
|
111
|
+
proposed_version=proposed_contract.version,
|
|
112
|
+
classification=classification,
|
|
113
|
+
is_valid_bump=is_valid_bump,
|
|
114
|
+
breaking_changes=breaking_changes,
|
|
115
|
+
affected_consumers=affected_consumers,
|
|
116
|
+
reasons=reasons,
|
|
117
|
+
warnings=warnings,
|
|
118
|
+
suggested_min_version=suggested_min_v,
|
|
119
|
+
)
|