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.
Files changed (40) hide show
  1. dbt_contracts/__init__.py +3 -0
  2. dbt_contracts/cli/__init__.py +5 -0
  3. dbt_contracts/cli/app.py +56 -0
  4. dbt_contracts/cli/check.py +112 -0
  5. dbt_contracts/cli/consumer.py +107 -0
  6. dbt_contracts/cli/ingest.py +64 -0
  7. dbt_contracts/cli/publish.py +97 -0
  8. dbt_contracts/cli/registry.py +62 -0
  9. dbt_contracts/compatibility/__init__.py +11 -0
  10. dbt_contracts/compatibility/engine.py +119 -0
  11. dbt_contracts/compatibility/rules/columns.py +79 -0
  12. dbt_contracts/compatibility/rules/consumers.py +76 -0
  13. dbt_contracts/compatibility/rules/metadata.py +29 -0
  14. dbt_contracts/compatibility/rules/nullability.py +45 -0
  15. dbt_contracts/compatibility/types.py +102 -0
  16. dbt_contracts/consumers/__init__.py +9 -0
  17. dbt_contracts/consumers/parser.py +112 -0
  18. dbt_contracts/consumers/resolver.py +20 -0
  19. dbt_contracts/dbt/__init__.py +11 -0
  20. dbt_contracts/dbt/ingestion.py +170 -0
  21. dbt_contracts/dbt/manifest.py +42 -0
  22. dbt_contracts/domain/__init__.py +51 -0
  23. dbt_contracts/domain/compatibility.py +66 -0
  24. dbt_contracts/domain/consumers.py +41 -0
  25. dbt_contracts/domain/contracts.py +49 -0
  26. dbt_contracts/domain/provenance.py +61 -0
  27. dbt_contracts/domain/versions.py +140 -0
  28. dbt_contracts/exceptions.py +37 -0
  29. dbt_contracts/registry/__init__.py +9 -0
  30. dbt_contracts/registry/protocol.py +61 -0
  31. dbt_contracts/registry/schema.py +78 -0
  32. dbt_contracts/registry/sqlite.py +229 -0
  33. dbt_contracts/reporting/__init__.py +9 -0
  34. dbt_contracts/reporting/console.py +131 -0
  35. dbt_contracts/reporting/json.py +16 -0
  36. dbt_data_contracts-0.1.0.dist-info/METADATA +210 -0
  37. dbt_data_contracts-0.1.0.dist-info/RECORD +40 -0
  38. dbt_data_contracts-0.1.0.dist-info/WHEEL +4 -0
  39. dbt_data_contracts-0.1.0.dist-info/entry_points.txt +2 -0
  40. dbt_data_contracts-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """dbt_contracts: A lightweight data contract control plane for multi-repository dbt ecosystems."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """CLI entry point and command modules."""
2
+
3
+ from dbt_contracts.cli.app import app
4
+
5
+ __all__ = ["app"]
@@ -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
+ )