From 9b796b0733ecdc91c791e9806227c39f48072bb4 Mon Sep 17 00:00:00 2001 From: Pierre Monnet Date: Wed, 19 Aug 2026 11:28:11 +0200 Subject: [PATCH 01/17] feat: add breaking change detection --- CHANGELOG.md | 1 + README.md | 3 + datacontract/api.py | 36 +++ datacontract/breaking/__init__.py | 4 + datacontract/breaking/detector.py | 76 ++++++ datacontract/breaking/rules.py | 235 +++++++++++++++++++ datacontract/cli.py | 2 + datacontract/command_breaking.py | 39 +++ datacontract/data_contract.py | 7 + datacontract/model/breaking.py | 39 +++ datacontract/output/text_breaking_results.py | 81 +++++++ docs/docs/commands/breaking.md | 29 +++ docs/docs/testing/breaking-changes.md | 42 ++++ tests/test_api.py | 20 ++ tests/test_breaking.py | 104 ++++++++ tests/test_cli.py | 26 ++ update_command_docs.py | 1 + 17 files changed, 745 insertions(+) create mode 100644 datacontract/breaking/__init__.py create mode 100644 datacontract/breaking/detector.py create mode 100644 datacontract/breaking/rules.py create mode 100644 datacontract/command_breaking.py create mode 100644 datacontract/model/breaking.py create mode 100644 datacontract/output/text_breaking_results.py create mode 100644 docs/docs/commands/breaking.md create mode 100644 docs/docs/testing/breaking-changes.md create mode 100644 tests/test_breaking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 169fb8a59..bcf06e9af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `postgresql` is accepted as the ODCS synonym of the `postgres` server type - `--dry-run` flag for `datacontract dbt sync` that reports the same plan as a real sync, but writes nothing to disk - `datacontract import pydantic-model` reads the contract description from the module docstring +- `datacontract breaking` command and `POST /breaking` endpoint for breaking change detection (#1482) ### Changed - `datacontract api` no longer sends permissive `Access-Control-Allow-Origin: *` headers; it serves no CORS headers at all, since the only browser client is the same-origin Swagger UI diff --git a/README.md b/README.md index 7ab4eb087..62469bf9b 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,9 @@ $ datacontract lint odcs.yaml # show a changelog between two data contracts $ datacontract changelog v1.odcs.yaml v2.odcs.yaml +# fail when a contract change is backward-incompatible +$ datacontract breaking v1.odcs.yaml v2.odcs.yaml + # execute schema and quality checks (define credentials as environment variables) $ datacontract test odcs.yaml diff --git a/datacontract/api.py b/datacontract/api.py index 4e41939e4..9fa23fdfe 100644 --- a/datacontract/api.py +++ b/datacontract/api.py @@ -863,6 +863,42 @@ async def changelog_endpoint( os.unlink(v2_path) +@app.post( + "/breaking", + tags=["breaking"], + summary="Show compatibility impact between two data contracts.", + description=""" + Compare two ODCS data contract YAMLs and classify their backward-compatibility impact. + POST a JSON body with `v1` (source/before) and `v2` (target/after) as YAML strings. + """, +) +async def breaking_endpoint( + body: ChangelogRequest, + api_key: Annotated[str | None, Depends(api_key_header)] = None, +): + check_api_key(api_key) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f1: + f1.write(body.v1) + v1_path = f1.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f2: + f2.write(body.v2) + v2_path = f2.name + + try: + result = DataContract(data_contract_file=v1_path).breaking(DataContract(data_contract_file=v2_path)) + return result + except yaml.YAMLError as e: + raise HTTPException(status_code=422, detail=f"Invalid YAML: {e}") + except pydantic.ValidationError as e: + raise HTTPException(status_code=422, detail=f"Invalid data contract: {e}") + except DataContractException as e: + raise HTTPException(status_code=422, detail=f"Data Contract Validation Failure: {e}") + finally: + os.unlink(v1_path) + os.unlink(v2_path) + + @app.post( "/export", tags=["export"], diff --git a/datacontract/breaking/__init__.py b/datacontract/breaking/__init__.py new file mode 100644 index 000000000..fe3797223 --- /dev/null +++ b/datacontract/breaking/__init__.py @@ -0,0 +1,4 @@ +from datacontract.breaking.detector import BreakingChangeDetector +from datacontract.breaking.rules import BreakingChangeRule, RuleEvaluation + +__all__ = ["BreakingChangeDetector", "BreakingChangeRule", "RuleEvaluation"] diff --git a/datacontract/breaking/detector.py b/datacontract/breaking/detector.py new file mode 100644 index 000000000..00dd7ee50 --- /dev/null +++ b/datacontract/breaking/detector.py @@ -0,0 +1,76 @@ +from collections.abc import Iterable + +from datacontract.breaking.rules import DEFAULT_RULES, BreakingChangeRule +from datacontract.model.breaking import BreakingChangeEntry, BreakingChangeLevel, BreakingChangeResult +from datacontract.model.changelog import ChangelogEntry, ChangelogResult + +_LEVEL_ORDER = { + BreakingChangeLevel.INFO: 0, + BreakingChangeLevel.WARNING: 1, + BreakingChangeLevel.ERROR: 2, +} + + +class BreakingChangeDetector: + """Classify detailed changelog entries using an ordered rule set.""" + + def __init__(self, rules: Iterable[BreakingChangeRule] = DEFAULT_RULES): + self._rules = tuple(rules) + if not self._rules: + raise ValueError("At least one breaking-change rule is required") + + def detect(self, changelog: ChangelogResult) -> BreakingChangeResult: + entries = [self._classify(entry) for entry in changelog.entries] + summary = [self._summarize(entry, entries) for entry in changelog.summary] + return BreakingChangeResult(v1=changelog.v1, v2=changelog.v2, summary=summary, entries=entries) + + def _classify(self, entry: ChangelogEntry) -> BreakingChangeEntry: + matches = [match for rule in self._rules if (match := rule.evaluate(entry)) is not None] + if not matches: + raise ValueError(f"No breaking-change rule classified {entry.path}") + highest_priority = max(self._priority(match.rule_id) for match in matches) + highest = [match for match in matches if self._priority(match.rule_id) == highest_priority] + if len({match.rule_id for match in highest}) > 1: + raise ValueError(f"Ambiguous breaking-change rules for {entry.path}: {highest}") + evaluation = highest[0] + return BreakingChangeEntry( + path=entry.path, + change_type=entry.type, + level=evaluation.level, + message=evaluation.message, + rule_id=evaluation.rule_id, + old_value=entry.old_value, + new_value=entry.new_value, + ) + + def _priority(self, rule_id: str) -> int: + for rule in self._rules: + if rule.rule_id == rule_id: + return rule.priority + raise ValueError(f"Unknown rule {rule_id}") + + @staticmethod + def _summarize(summary_entry: ChangelogEntry, entries: list[BreakingChangeEntry]) -> BreakingChangeEntry: + matching = [ + entry + for entry in entries + if entry.path == summary_entry.path or entry.path.startswith(f"{summary_entry.path}.") + ] + if not matching: + return BreakingChangeEntry( + path=summary_entry.path, + change_type=summary_entry.type, + level=BreakingChangeLevel.INFO, + message=f"Changed contract at {summary_entry.path}", + rule_id="summary-fallback", + ) + highest = max(matching, key=lambda entry: _LEVEL_ORDER[entry.level]) + return BreakingChangeEntry( + path=summary_entry.path, + change_type=summary_entry.type, + level=highest.level, + message=f"{highest.level.value.capitalize()} impact at {summary_entry.path}", + rule_id=f"summary:{highest.rule_id}", + old_value=highest.old_value, + new_value=highest.new_value, + ) diff --git a/datacontract/breaking/rules.py b/datacontract/breaking/rules.py new file mode 100644 index 000000000..7f6984ff5 --- /dev/null +++ b/datacontract/breaking/rules.py @@ -0,0 +1,235 @@ +import ast +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +from datacontract.model.breaking import BreakingChangeLevel +from datacontract.model.changelog import ChangelogEntry, ChangelogType + + +@dataclass(frozen=True) +class RuleEvaluation: + rule_id: str + level: BreakingChangeLevel + message: str + + +class BreakingChangeRule(ABC): + priority: int = 0 + rule_id: str + + @abstractmethod + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + """Return a classification when this rule applies to ``entry``.""" + + +class SchemaRemovedRule(BreakingChangeRule): + priority = 90 + rule_id = "schema-removed" + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + segments = entry.path.split(".") + if entry.type == ChangelogType.removed and len(segments) == 2 and segments[0] == "schema": + return RuleEvaluation(self.rule_id, BreakingChangeLevel.ERROR, f"Removed schema {segments[1]}") + return None + + +class FieldRemovedRule(BreakingChangeRule): + priority = 90 + rule_id = "field-removed" + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + segments = entry.path.split(".") + if entry.type != ChangelogType.removed or not _is_schema_property_path(segments): + return None + property_name = segments[segments.index("properties") + 1] + return RuleEvaluation(self.rule_id, BreakingChangeLevel.ERROR, f"Removed property {property_name}") + + +class RequiredChangedRule(BreakingChangeRule): + priority = 100 + rule_id = "required-changed" + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + if not entry.path.endswith(".required"): + return None + old = _parse_bool(entry.old_value) + new = _parse_bool(entry.new_value) + if entry.type == ChangelogType.added and new is True: + level = BreakingChangeLevel.ERROR + elif entry.type == ChangelogType.removed or (old is True and new is False): + level = BreakingChangeLevel.INFO + elif entry.type == ChangelogType.updated and old is False and new is True: + level = BreakingChangeLevel.ERROR + elif entry.type in (ChangelogType.added, ChangelogType.updated): + level = BreakingChangeLevel.WARNING + else: + return None + return RuleEvaluation(self.rule_id, level, _change_message("requiredness", entry)) + + +class TypeChangedRule(BreakingChangeRule): + priority = 80 + rule_id = "type-changed" + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + if not entry.path.endswith((".logicalType", ".physicalType")): + return None + if entry.type == ChangelogType.updated: + level = BreakingChangeLevel.ERROR + elif entry.type in (ChangelogType.added, ChangelogType.removed): + level = BreakingChangeLevel.WARNING + else: + return None + return RuleEvaluation(self.rule_id, level, _change_message("type", entry)) + + +class UniqueConstraintRule(BreakingChangeRule): + priority = 75 + rule_id = "unique-constraint-changed" + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + if not entry.path.endswith(".unique"): + return None + old = _parse_bool(entry.old_value) + new = _parse_bool(entry.new_value) + if new is True and old is not True: + level = BreakingChangeLevel.ERROR + elif old is True and new is not True: + level = BreakingChangeLevel.INFO + else: + level = BreakingChangeLevel.WARNING + return RuleEvaluation(self.rule_id, level, _change_message("uniqueness", entry)) + + +class KeyConstraintRule(BreakingChangeRule): + priority = 70 + rule_id = "key-constraint-changed" + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + if not entry.path.endswith((".primaryKey", ".primary_key")): + return None + return RuleEvaluation(self.rule_id, BreakingChangeLevel.WARNING, _change_message("key constraint", entry)) + + +class EnumConstraintRule(BreakingChangeRule): + priority = 65 + rule_id = "enum-constraint-changed" + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + if not entry.path.endswith(".enum"): + return None + old = _parse_sequence(entry.old_value) + new = _parse_sequence(entry.new_value) + if old is not None and new is not None: + if set(old) - set(new): + level = BreakingChangeLevel.ERROR + elif set(new) - set(old): + level = BreakingChangeLevel.INFO + else: + level = BreakingChangeLevel.INFO + else: + level = BreakingChangeLevel.WARNING + return RuleEvaluation(self.rule_id, level, _change_message("enum constraint", entry)) + + +class ValidationConstraintRule(BreakingChangeRule): + priority = 60 + rule_id = "validation-constraint-changed" + _suffixes = ( + ".pattern", + ".minLength", + ".maxLength", + ".minimum", + ".maximum", + ".exclusiveMinimum", + ".exclusiveMaximum", + ) + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: + if not entry.path.endswith(self._suffixes): + return None + old = _parse_number(entry.old_value) + new = _parse_number(entry.new_value) + if old is None or new is None: + level = BreakingChangeLevel.WARNING + elif _is_tightening(entry.path, old, new): + level = BreakingChangeLevel.ERROR + else: + level = BreakingChangeLevel.INFO + return RuleEvaluation(self.rule_id, level, _change_message("validation constraint", entry)) + + +class MetadataFallbackRule(BreakingChangeRule): + priority = -100 + rule_id = "metadata-or-unknown-change" + + def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation: + return RuleEvaluation(self.rule_id, BreakingChangeLevel.INFO, _change_message("contract", entry)) + + +def _is_schema_property_path(segments: list[str]) -> bool: + try: + properties_index = segments.index("properties") + except ValueError: + return False + return segments[0] == "schema" and properties_index + 1 < len(segments) + + +def _parse_bool(value: str | None) -> bool | None: + if value is None: + return None + normalized = value.strip().lower() + if normalized in {"true", "1"}: + return True + if normalized in {"false", "0"}: + return False + return None + + +def _parse_number(value: str | None) -> float | None: + if value is None: + return None + try: + return float(value.strip()) + except ValueError: + return None + + +def _parse_sequence(value: str | None) -> list[Any] | None: + if value is None: + return None + try: + parsed = ast.literal_eval(value) + except (SyntaxError, ValueError): + return None + return parsed if isinstance(parsed, list) else None + + +def _is_tightening(path: str, old: float, new: float) -> bool: + if path.endswith((".minLength", ".minimum", ".exclusiveMinimum")): + return new > old + if path.endswith((".maxLength", ".maximum", ".exclusiveMaximum")): + return new < old + return True + + +def _change_message(subject: str, entry: ChangelogEntry) -> str: + if entry.type == ChangelogType.added: + return f"Added {subject} at {entry.path}" + if entry.type == ChangelogType.removed: + return f"Removed {subject} at {entry.path}" + return f"Changed {subject} at {entry.path} from {entry.old_value!r} to {entry.new_value!r}" + + +DEFAULT_RULES: tuple[BreakingChangeRule, ...] = ( + RequiredChangedRule(), + SchemaRemovedRule(), + FieldRemovedRule(), + TypeChangedRule(), + UniqueConstraintRule(), + KeyConstraintRule(), + EnumConstraintRule(), + ValidationConstraintRule(), + MetadataFallbackRule(), +) diff --git a/datacontract/cli.py b/datacontract/cli.py index b71f2412b..8fbc4d135 100644 --- a/datacontract/cli.py +++ b/datacontract/cli.py @@ -28,6 +28,7 @@ "edit", "lint", "changelog", + "breaking", "sync", # `dbt sync` subcommand; no top-level `sync`, so this only orders the dbt group "test", "ci", @@ -252,6 +253,7 @@ def _print_logs(run, out=None): # Display order for `--help` is controlled by COMMAND_ORDER above, not by import order. from datacontract import ( # noqa: E402, F401 command_api, + command_breaking, command_catalog, command_changelog, command_ci, diff --git a/datacontract/command_breaking.py b/datacontract/command_breaking.py new file mode 100644 index 000000000..02c447e53 --- /dev/null +++ b/datacontract/command_breaking.py @@ -0,0 +1,39 @@ +import typer +from typing_extensions import Annotated + +from datacontract.cli import app, console, debug_option, enable_debug_logging +from datacontract.config import cli_config +from datacontract.data_contract import DataContract +from datacontract.output.text_breaking_results import write_text_breaking_results + + +@app.command( + name="breaking", + epilog="Example: datacontract breaking datacontract-v1.yaml datacontract-v2.yaml", +) +def breaking( + v1: Annotated[ + str, + typer.Argument(help="The location (url, s3 url, or local path) of the source (before) data contract YAML."), + ], + v2: Annotated[ + str, + typer.Argument(help="The location (url, s3 url, or local path) of the target (after) data contract YAML."), + ], + inline_references: Annotated[ + bool, + typer.Option( + help="Resolve external references (currently: authoritativeDefinitions\\[type in {definition, semantics}]) " + "in the contract and inline the fetched content from the configured entropy-data host." + ), + ] = True, + debug: debug_option = None, +): + """Show compatibility impact between two data contracts.""" + enable_debug_logging(debug) + result = DataContract(config=cli_config(), data_contract_file=v1, inline_references=inline_references).breaking( + DataContract(config=cli_config(), data_contract_file=v2, inline_references=inline_references) + ) + write_text_breaking_results(result, console) + if result.is_breaking: + raise typer.Exit(code=1) diff --git a/datacontract/data_contract.py b/datacontract/data_contract.py index 0eb94dbee..67f9537cb 100644 --- a/datacontract/data_contract.py +++ b/datacontract/data_contract.py @@ -9,6 +9,7 @@ from duckdb.duckdb import DuckDBPyConnection from pyspark.sql import SparkSession +from datacontract.breaking.detector import BreakingChangeDetector from datacontract.config import Config from datacontract.engines.data_contract_test import execute_data_contract_test from datacontract.export.exporter import ExportFormat @@ -17,6 +18,7 @@ from datacontract.init.init_template import get_init_template from datacontract.integration.entropy_data import publish_test_results_to_entropy_data from datacontract.lint import resolve +from datacontract.model.breaking import BreakingChangeResult from datacontract.model.changelog import ChangelogEntry, ChangelogResult, ChangelogType from datacontract.model.exceptions import DataContractException, DataContractValidationErrors from datacontract.model.run import Check, ResultEnum, Run @@ -276,6 +278,11 @@ def changelog(self, other: "DataContract") -> ChangelogResult: ) return result + def breaking(self, other: "DataContract", detector: BreakingChangeDetector | None = None) -> BreakingChangeResult: + """Classify the changelog between this contract and another for compatibility impact.""" + changelog = self.changelog(other) + return (detector or BreakingChangeDetector()).detect(changelog) + @classmethod def import_from_source( cls, diff --git a/datacontract/model/breaking.py b/datacontract/model/breaking.py new file mode 100644 index 000000000..5fe9f6b69 --- /dev/null +++ b/datacontract/model/breaking.py @@ -0,0 +1,39 @@ +from enum import Enum + +from pydantic import BaseModel, Field, computed_field + +from datacontract.model.changelog import ChangelogType + + +class BreakingChangeLevel(str, Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class BreakingChangeEntry(BaseModel): + path: str + change_type: ChangelogType + level: BreakingChangeLevel + message: str + rule_id: str + old_value: str | None = None + new_value: str | None = None + + +class BreakingChangeResult(BaseModel): + v1: str + v2: str + summary: list[BreakingChangeEntry] = Field(default_factory=list) + entries: list[BreakingChangeEntry] = Field(default_factory=list) + + @computed_field + @property + def is_breaking(self) -> bool: + return any(entry.level == BreakingChangeLevel.ERROR for entry in self.entries) + + def has_changes(self) -> bool: + return bool(self.entries) + + def pretty(self) -> str: + return self.model_dump_json(indent=2) diff --git a/datacontract/output/text_breaking_results.py b/datacontract/output/text_breaking_results.py new file mode 100644 index 000000000..8fd2fc674 --- /dev/null +++ b/datacontract/output/text_breaking_results.py @@ -0,0 +1,81 @@ +import io +from collections import Counter + +from rich import box +from rich.console import Console +from rich.table import Table + +from datacontract.model.breaking import BreakingChangeEntry, BreakingChangeLevel, BreakingChangeResult +from datacontract.output.text_changelog_results import _wrap + +_VAL_W = 30 +_LEVEL_ORDER = [BreakingChangeLevel.ERROR, BreakingChangeLevel.WARNING, BreakingChangeLevel.INFO] +_LEVEL_COLOR = { + BreakingChangeLevel.ERROR: "red", + BreakingChangeLevel.WARNING: "yellow", + BreakingChangeLevel.INFO: "green", +} + + +def write_text_breaking_results(result: BreakingChangeResult, console: Console): + _print_summary(result, console) + _print_table(result, console) + + +def _badges(entries: list[BreakingChangeEntry]) -> str: + counts = Counter(entry.level for entry in entries) + parts = [] + for level in _LEVEL_ORDER: + count = counts[level] + if count: + color = _LEVEL_COLOR[level] + parts.append(f"[ [{color}]{count} {level.value.capitalize()}[/{color}] ]") + return " ".join(parts) + + +def _print_summary(result: BreakingChangeResult, console: Console): + if not result.summary: + return + console.print("Summary") + console.print(_badges(result.summary)) + table = Table(box=box.ROUNDED, show_header=True) + table.add_column("Severity", no_wrap=True) + table.add_column("Change", no_wrap=True) + table.add_column("Field", no_wrap=True) + for entry in result.summary: + table.add_row(_severity_markup(entry.level), entry.change_type.value.capitalize(), entry.path) + _print_wide(table, console) + + +def _print_table(result: BreakingChangeResult, console: Console): + console.print("Details") + table = Table(box=box.ROUNDED) + table.add_column("Severity", no_wrap=True) + table.add_column("Change", no_wrap=True) + table.add_column("Path", no_wrap=True) + table.add_column("Old Value", max_width=_VAL_W, no_wrap=True) + table.add_column("New Value", max_width=_VAL_W, no_wrap=True) + table.add_column("Message", max_width=_VAL_W, no_wrap=True) + for entry in result.entries: + table.add_row( + _severity_markup(entry.level), + entry.change_type.value.capitalize(), + entry.path, + _wrap(entry.old_value or "", _VAL_W), + _wrap(entry.new_value or "", _VAL_W), + _wrap(entry.message, _VAL_W), + ) + _print_wide(table, console) + + +def _severity_markup(level: BreakingChangeLevel) -> str: + color = _LEVEL_COLOR[level] + return f"[{color}]{level.value.upper()}[/{color}]" + + +def _print_wide(table: Table, console: Console): + buf = io.StringIO() + wide = Console(file=buf, width=300, highlight=False, force_terminal=console.is_terminal, no_color=console.no_color) + wide.print(table) + print(buf.getvalue(), end="") + print("") diff --git a/docs/docs/commands/breaking.md b/docs/docs/commands/breaking.md new file mode 100644 index 000000000..587153dbe --- /dev/null +++ b/docs/docs/commands/breaking.md @@ -0,0 +1,29 @@ +--- +sidebar_position: 5 +title: "breaking" +description: "Show compatibility impact between two data contracts." +--- + +# `datacontract breaking` + +{/* AUTOGENERATED from `datacontract --help`: do not edit by hand; regenerate with update_command_docs.py */} + +Show compatibility impact between two data contracts. + +```bash +datacontract breaking [OPTIONS] V1 V2 +``` + +| Argument | Default | Description | +|---|---|---| +| `V1` | required | The location (url, s3 url, or local path) of the source (before) data contract YAML. | +| `V2` | required | The location (url, s3 url, or local path) of the target (after) data contract YAML. | + +| Option | Default | Description | +|---|---|---| +| `--inline-references` / `--no-inline-references` | `--inline-references` | Resolve external references (currently: authoritativeDefinitions\[type in \{definition, semantics\}]) in the contract and inline the fetched content from the configured entropy-data host. | +| `--debug` / `--no-debug` | — | Enable debug logging | + +```bash +datacontract breaking datacontract-v1.yaml datacontract-v2.yaml +``` diff --git a/docs/docs/testing/breaking-changes.md b/docs/docs/testing/breaking-changes.md new file mode 100644 index 000000000..dce3aefd7 --- /dev/null +++ b/docs/docs/testing/breaking-changes.md @@ -0,0 +1,42 @@ +--- +sidebar_position: 7 +title: "Detect breaking changes" +description: "Compare two ODCS data contracts and classify backward-compatibility impact." +--- + +# Detect breaking changes + +Use `datacontract breaking` when a contract change must be checked for backward compatibility. The command compares the source contract (`v1`) with the target contract (`v2`) and preserves the detailed changelog while adding a severity classification. + +```bash +datacontract breaking v1.odcs.yaml v2.odcs.yaml +``` + +See the generated [breaking command reference](../commands/breaking.md) for all options. + +## Severity levels + +- **ERROR** — a backward-incompatible change. The command exits with status `1`. +- **WARNING** — a potentially incompatible change that requires review. The command exits with status `0`. +- **INFO** — informational or currently unclassified metadata. The command exits with status `0`. + +The result is breaking only when at least one detailed entry has severity `ERROR`. + +## Initial compatibility rules + +The first ODCS implementation treats schema and property removals, requiredness tightening, type changes, uniqueness tightening, and enum narrowing as errors. Primary-key changes and changes to validation constraints whose direction cannot be proven are warnings. Additions, relaxed constraints, descriptions, tags, business names, custom properties, and unrecognized changes are informational unless a more specific rule applies. + +Every detailed changelog entry receives exactly one classification. Unknown fields use the informational fallback so that introducing a new ODCS field does not make detection fail. + +## API + +The same result is available from `POST /breaking`, using the same JSON request shape as `POST /changelog`: + +```json +{ + "v1": "", + "v2": "" +} +``` + +The response includes `summary`, `entries`, and an `is_breaking` boolean. The endpoint returns HTTP `200` for a valid comparison even when `is_breaking` is `true`; invalid YAML or contracts return HTTP `422`. diff --git a/tests/test_api.py b/tests/test_api.py index 1f524923e..38e664a3a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -67,6 +67,26 @@ def test_changelog_invalid_yaml(): assert "Cannot parse YAML" in detail +def test_breaking(): + with open("fixtures/changelog/integration/changelog_integration_v1.yaml", "r") as f: + v1 = f.read() + with open("fixtures/changelog/integration/changelog_integration_v2.yaml", "r") as f: + v2 = f.read() + response = client.post(url="/breaking", json={"v1": v1, "v2": v2}) + assert response.status_code == 200 + data = response.json() + assert data["is_breaking"] is True + assert "summary" in data + assert "entries" in data + assert data["entries"][0]["level"] in ("info", "warning", "error") + assert "message" in data["entries"][0] + + +def test_breaking_invalid_yaml(): + response = client.post(url="/breaking", json={"v1": "invalid: yaml: [", "v2": "valid: yaml"}) + assert response.status_code == 422 + + def test_changelog_invalid_data_contract(): invalid_contract = """ apiVersion: '1.0' diff --git a/tests/test_breaking.py b/tests/test_breaking.py new file mode 100644 index 000000000..5a09a9ba5 --- /dev/null +++ b/tests/test_breaking.py @@ -0,0 +1,104 @@ +import pytest + +from datacontract.breaking.detector import BreakingChangeDetector +from datacontract.breaking.rules import ( + BreakingChangeRule, + RequiredChangedRule, + RuleEvaluation, + TypeChangedRule, +) +from datacontract.data_contract import DataContract +from datacontract.model.breaking import BreakingChangeLevel +from datacontract.model.changelog import ChangelogEntry, ChangelogResult, ChangelogType + +V1 = "fixtures/changelog/integration/changelog_integration_v1.yaml" +V2 = "fixtures/changelog/integration/changelog_integration_v2.yaml" + + +def _entry(path, change_type, old_value=None, new_value=None): + return ChangelogEntry(path=path, type=change_type, old_value=old_value, new_value=new_value) + + +def test_required_change_to_true_is_error(): + result = RequiredChangedRule().evaluate( + _entry("schema.orders.properties.customer_id.required", ChangelogType.updated, "False", "True") + ) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + + +def test_required_change_to_false_is_info(): + result = RequiredChangedRule().evaluate( + _entry("schema.orders.properties.customer_id.required", ChangelogType.updated, "true", "false") + ) + assert result is not None + assert result.level == BreakingChangeLevel.INFO + + +def test_type_change_is_error(): + result = TypeChangedRule().evaluate( + _entry("schema.orders.properties.order_id.logicalType", ChangelogType.updated, "string", "integer") + ) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + + +def test_unmatched_entry_uses_info_fallback(): + changelog = ChangelogResult( + v1="v1", + v2="v2", + entries=[_entry("description.purpose", ChangelogType.updated, "old", "new")], + ) + result = BreakingChangeDetector().detect(changelog) + assert result.entries[0].level == BreakingChangeLevel.INFO + assert result.entries[0].rule_id == "metadata-or-unknown-change" + assert not result.is_breaking + + +def test_summary_uses_highest_detail_severity(): + changelog = ChangelogResult( + v1="v1", + v2="v2", + summary=[_entry("schema.orders", ChangelogType.updated)], + entries=[ + _entry("schema.orders.description", ChangelogType.updated, "old", "new"), + _entry("schema.orders.properties.id", ChangelogType.removed, "string"), + ], + ) + result = BreakingChangeDetector().detect(changelog) + assert result.summary[0].level == BreakingChangeLevel.ERROR + + +def test_every_detail_entry_is_classified(): + changelog = DataContract(data_contract_file=V1).changelog(DataContract(data_contract_file=V2)) + result = BreakingChangeDetector().detect(changelog) + assert len(result.entries) == len(changelog.entries) + assert all(isinstance(entry.level, BreakingChangeLevel) for entry in result.entries) + + +def test_data_contract_breaking_reuses_changelog(): + result = DataContract(data_contract_file=V1).breaking(DataContract(data_contract_file=V2)) + assert result.v1 == V1 + assert result.v2 == V2 + assert result.is_breaking + + +def test_detector_rejects_ambiguous_rules(): + class FirstRule(BreakingChangeRule): + priority = 10 + rule_id = "first" + + def evaluate(self, entry): + return RuleEvaluation(self.rule_id, BreakingChangeLevel.INFO, "first") + + class SecondRule(BreakingChangeRule): + priority = 10 + rule_id = "second" + + def evaluate(self, entry): + return RuleEvaluation(self.rule_id, BreakingChangeLevel.WARNING, "second") + + with pytest.raises(ValueError, match="Ambiguous"): + BreakingChangeDetector((FirstRule(), SecondRule())).detect( + ChangelogResult(v1="v1", v2="v2", entries=[_entry("field", ChangelogType.updated)]) + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 155e3526e..fb2e4d49a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -130,6 +130,32 @@ def test_changelog_with_changes(): assert "Added" in result.output +def test_breaking_help(): + result = runner.invoke(app, ["breaking", "--help"]) + assert result.exit_code == 0 + + +def test_breaking_with_changes_exits_nonzero_and_shows_severity(): + result = runner.invoke( + app, + [ + "breaking", + "fixtures/changelog/integration/changelog_integration_v1.yaml", + "fixtures/changelog/integration/changelog_integration_v2.yaml", + ], + ) + assert result.exit_code == 1 + assert "Severity" in result.output + assert "ERROR" in result.output + + +def test_breaking_without_changes_exits_zero(): + fixture = "fixtures/changelog/integration/changelog_integration_v1.yaml" + result = runner.invoke(app, ["breaking", fixture, fixture]) + assert result.exit_code == 0 + assert "Details" in result.output + + def test_error_message_keeps_bracketed_text(monkeypatch, capsys): """Rich markup must not eat hints like `pip install "botocore[crt]"`.""" from datacontract import cli diff --git a/update_command_docs.py b/update_command_docs.py index ac0472b85..1e9c8d14f 100644 --- a/update_command_docs.py +++ b/update_command_docs.py @@ -38,6 +38,7 @@ "edit", "lint", "changelog", + "breaking", "test", "dbt", "ci", From 7d470d36f129a4f9d4de5ef4407430823fc3fce2 Mon Sep 17 00:00:00 2001 From: Pierre Monnet Date: Wed, 19 Aug 2026 13:45:32 +0200 Subject: [PATCH 02/17] fix: sidebar_position --- docs/docs/testing/api.md | 2 +- docs/docs/testing/bigquery.md | 2 +- docs/docs/testing/dataframe.md | 2 +- docs/docs/testing/duckdb.md | 2 +- docs/docs/testing/gcs.md | 2 +- docs/docs/testing/kafka.md | 2 +- docs/docs/testing/local.md | 2 +- docs/docs/testing/mysql.md | 2 +- docs/docs/testing/oracle.md | 2 +- docs/docs/testing/postgres.md | 2 +- docs/docs/testing/snowflake.md | 2 +- docs/docs/testing/sqlserver.md | 2 +- docs/docs/testing/trino.md | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/docs/testing/api.md b/docs/docs/testing/api.md index 280124afe..721e4fc29 100644 --- a/docs/docs/testing/api.md +++ b/docs/docs/testing/api.md @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 11 title: "HTTP API" description: "Create a data contract from a JSON HTTP API and test the responses against it (GET requests only)." --- diff --git a/docs/docs/testing/bigquery.md b/docs/docs/testing/bigquery.md index f9e4b62b8..926b727a1 100644 --- a/docs/docs/testing/bigquery.md +++ b/docs/docs/testing/bigquery.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 title: "Google BigQuery" description: "Create a data contract from your BigQuery tables and test the actual data against it — in about 5 minutes." --- diff --git a/docs/docs/testing/dataframe.md b/docs/docs/testing/dataframe.md index 755364ca3..c1f58015e 100644 --- a/docs/docs/testing/dataframe.md +++ b/docs/docs/testing/dataframe.md @@ -1,5 +1,5 @@ --- -sidebar_position: 18 +sidebar_position: 19 title: "Spark DataFrame" description: "Test in-memory Spark DataFrames in a pipeline (programmatic)." --- diff --git a/docs/docs/testing/duckdb.md b/docs/docs/testing/duckdb.md index 8ded36968..8d9ca7429 100644 --- a/docs/docs/testing/duckdb.md +++ b/docs/docs/testing/duckdb.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 title: "DuckDB" description: "Test the tables inside a DuckDB database file." --- diff --git a/docs/docs/testing/gcs.md b/docs/docs/testing/gcs.md index d22b8dadd..9e23a81e5 100644 --- a/docs/docs/testing/gcs.md +++ b/docs/docs/testing/gcs.md @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 10 title: "Google Cloud Storage" description: "Create a data contract from files on Google Cloud Storage and test them against it." --- diff --git a/docs/docs/testing/kafka.md b/docs/docs/testing/kafka.md index 17a6ddb1e..305ec852a 100644 --- a/docs/docs/testing/kafka.md +++ b/docs/docs/testing/kafka.md @@ -1,5 +1,5 @@ --- -sidebar_position: 11 +sidebar_position: 12 title: "Kafka" description: "Create a data contract for a Kafka topic and test the messages against it (experimental)." --- diff --git a/docs/docs/testing/local.md b/docs/docs/testing/local.md index 9b0c51ed9..0a185b729 100644 --- a/docs/docs/testing/local.md +++ b/docs/docs/testing/local.md @@ -1,5 +1,5 @@ --- -sidebar_position: 12 +sidebar_position: 13 title: "Local files" description: "Test local files in Parquet, JSON, CSV, or Delta format — the fastest way to try the CLI, no credentials needed." --- diff --git a/docs/docs/testing/mysql.md b/docs/docs/testing/mysql.md index 93b380ed7..56b68bea2 100644 --- a/docs/docs/testing/mysql.md +++ b/docs/docs/testing/mysql.md @@ -1,5 +1,5 @@ --- -sidebar_position: 14 +sidebar_position: 15 title: "MySQL" description: "Create a data contract from your MySQL tables and test the actual data against it." --- diff --git a/docs/docs/testing/oracle.md b/docs/docs/testing/oracle.md index c702c8dd9..dd746d1da 100644 --- a/docs/docs/testing/oracle.md +++ b/docs/docs/testing/oracle.md @@ -1,5 +1,5 @@ --- -sidebar_position: 15 +sidebar_position: 16 title: "Oracle" description: "Create a data contract from your Oracle tables and test the actual data against it." --- diff --git a/docs/docs/testing/postgres.md b/docs/docs/testing/postgres.md index ec9f4d3cb..b96b9413e 100644 --- a/docs/docs/testing/postgres.md +++ b/docs/docs/testing/postgres.md @@ -1,5 +1,5 @@ --- -sidebar_position: 16 +sidebar_position: 17 title: "Postgres" description: "Create a data contract from your Postgres tables and test the actual data against it — in about 5 minutes." --- diff --git a/docs/docs/testing/snowflake.md b/docs/docs/testing/snowflake.md index fe1251610..4cd9b21fa 100644 --- a/docs/docs/testing/snowflake.md +++ b/docs/docs/testing/snowflake.md @@ -1,5 +1,5 @@ --- -sidebar_position: 17 +sidebar_position: 18 title: "Snowflake" description: "Create a data contract from your Snowflake tables and test the actual data against it — in about 5 minutes." --- diff --git a/docs/docs/testing/sqlserver.md b/docs/docs/testing/sqlserver.md index 1c1231808..24c6fdbfe 100644 --- a/docs/docs/testing/sqlserver.md +++ b/docs/docs/testing/sqlserver.md @@ -1,5 +1,5 @@ --- -sidebar_position: 13 +sidebar_position: 14 title: "Microsoft SQL Server" description: "Create a data contract from your SQL Server tables and test the actual data against it." --- diff --git a/docs/docs/testing/trino.md b/docs/docs/testing/trino.md index 47f645c1f..144b76a7c 100644 --- a/docs/docs/testing/trino.md +++ b/docs/docs/testing/trino.md @@ -1,5 +1,5 @@ --- -sidebar_position: 19 +sidebar_position: 20 title: "Trino" description: "Create a data contract from your Trino tables and test the actual data against it." --- From d3a19ca8753999461abacdcc5f2fc9045c61c34f Mon Sep 17 00:00:00 2001 From: Pierre Monnet Date: Wed, 19 Aug 2026 21:00:12 +0200 Subject: [PATCH 03/17] test: add CLI tests for breaking changes --- .../changelog/breaking/warning_only_v1.yaml | 12 +++++++++ .../changelog/breaking/warning_only_v2.yaml | 15 +++++++++++ tests/test_breaking.py | 25 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 tests/fixtures/changelog/breaking/warning_only_v1.yaml create mode 100644 tests/fixtures/changelog/breaking/warning_only_v2.yaml diff --git a/tests/fixtures/changelog/breaking/warning_only_v1.yaml b/tests/fixtures/changelog/breaking/warning_only_v1.yaml new file mode 100644 index 000000000..6cae5530a --- /dev/null +++ b/tests/fixtures/changelog/breaking/warning_only_v1.yaml @@ -0,0 +1,12 @@ +apiVersion: v3.0.2 +kind: DataContract +id: orders-contract-001 +status: active +version: 1.0.0 +schema: + - name: orders + physicalName: orders_tbl + properties: + - name: order_id + logicalType: string + required: true \ No newline at end of file diff --git a/tests/fixtures/changelog/breaking/warning_only_v2.yaml b/tests/fixtures/changelog/breaking/warning_only_v2.yaml new file mode 100644 index 000000000..422d6b98d --- /dev/null +++ b/tests/fixtures/changelog/breaking/warning_only_v2.yaml @@ -0,0 +1,15 @@ +apiVersion: v3.0.2 +kind: DataContract +id: orders-contract-001 +status: active +version: 2.0.0 +schema: + - name: orders + physicalName: orders_tbl + properties: + - name: order_id + logicalType: string + required: true + - name: region + logicalType: string + required: false \ No newline at end of file diff --git a/tests/test_breaking.py b/tests/test_breaking.py index 5a09a9ba5..5c7eaeb9a 100644 --- a/tests/test_breaking.py +++ b/tests/test_breaking.py @@ -1,4 +1,5 @@ import pytest +from typer.testing import CliRunner from datacontract.breaking.detector import BreakingChangeDetector from datacontract.breaking.rules import ( @@ -7,12 +8,17 @@ RuleEvaluation, TypeChangedRule, ) +from datacontract.cli import app from datacontract.data_contract import DataContract from datacontract.model.breaking import BreakingChangeLevel from datacontract.model.changelog import ChangelogEntry, ChangelogResult, ChangelogType V1 = "fixtures/changelog/integration/changelog_integration_v1.yaml" V2 = "fixtures/changelog/integration/changelog_integration_v2.yaml" +WARNING_ONLY_V1 = "fixtures/changelog/breaking/warning_only_v1.yaml" +WARNING_ONLY_V2 = "fixtures/changelog/breaking/warning_only_v2.yaml" + +runner = CliRunner() def _entry(path, change_type, old_value=None, new_value=None): @@ -83,6 +89,25 @@ def test_data_contract_breaking_reuses_changelog(): assert result.is_breaking +def test_cli_warning_only_change_exits_zero_but_shows_warning(): + result = runner.invoke(app, ["breaking", WARNING_ONLY_V1, WARNING_ONLY_V2]) + assert result.exit_code == 0 + assert "[ 1 Warning ] [ 1 Info ]" in result.output + + +def test_cli_missing_file_exits_nonzero(): + result = runner.invoke(app, ["breaking", "unknown.yaml", "unknown.yaml"]) + assert result.exit_code == 1 + assert result.exception is not None + assert "The file 'unknown.yaml' does not exist." in str(result.exception) + + +def test_cli_shows_full_severity_range_for_mixed_changes(): + result = runner.invoke(app, ["breaking", V1, V2]) + assert result.exit_code == 1 + assert "[ 4 Error ] [ 1 Warning ] [ 3 Info ]" in result.output + + def test_detector_rejects_ambiguous_rules(): class FirstRule(BreakingChangeRule): priority = 10 From 8a08c4d755cf49964f88d851e6f2c946ba947510 Mon Sep 17 00:00:00 2001 From: Pierre Monnet Date: Sun, 23 Aug 2026 18:11:01 +0200 Subject: [PATCH 04/17] feat: implement breaking changes detection and response structure - Add BreakingChangesResponse model to provide detailed breaking change information. - Update breaking endpoint to return structured response for compatibility impact. - Introduce new rules for detecting breaking changes in schema and properties. - Enhance tests for breaking changes detection and validation rules. - Create documentation for comparing contract versions and breaking changes. - Clean up and reorganize existing documentation for clarity. --- datacontract/api.py | 37 ++++++- datacontract/breaking/detector.py | 43 ++++---- datacontract/breaking/rules.py | 54 ++-------- docs/docs/compare-contract-versions.md | 56 ++++++++++ docs/docs/schema.md | 1 - docs/docs/testing/api.md | 2 +- docs/docs/testing/bigquery.md | 2 +- docs/docs/testing/breaking-changes.md | 42 -------- docs/docs/testing/dataframe.md | 2 +- docs/docs/testing/duckdb.md | 2 +- docs/docs/testing/gcs.md | 2 +- docs/docs/testing/kafka.md | 2 +- docs/docs/testing/local.md | 2 +- docs/docs/testing/mysql.md | 2 +- docs/docs/testing/oracle.md | 2 +- docs/docs/testing/postgres.md | 2 +- docs/docs/testing/snowflake.md | 2 +- docs/docs/testing/sqlserver.md | 2 +- docs/docs/testing/trino.md | 2 +- docs/docusaurus.config.ts | 35 ++++--- tests/test_api.py | 2 + tests/test_breaking.py | 139 +++++++++++++++++++++++-- 22 files changed, 280 insertions(+), 155 deletions(-) create mode 100644 docs/docs/compare-contract-versions.md delete mode 100644 docs/docs/testing/breaking-changes.md diff --git a/datacontract/api.py b/datacontract/api.py index 9fa23fdfe..9ecb15335 100644 --- a/datacontract/api.py +++ b/datacontract/api.py @@ -15,6 +15,7 @@ from datacontract.config import Config, known_env_names from datacontract.data_contract import DataContract, ExportFormat +from datacontract.model.breaking import BreakingChangeEntry from datacontract.model.changelog import ChangelogEntry from datacontract.model.exceptions import DataContractException from datacontract.model.run import Check, ResultEnum, Run @@ -814,6 +815,20 @@ class ChangelogResponse(BaseModel): ) +class BreakingChangesResponse(BaseModel): + """The breaking changes between two versions of a data contract.""" + + summary: list[BreakingChangeEntry] = Field( + description="One entry per breaking change, rolled up to the level a reader cares about.", + ) + entries: list[BreakingChangeEntry] = Field( + description="Every individual breaking change, with the old and new value.", + ) + is_breaking: bool = Field( + description="Whether any detected change is classified as an error-level breaking change.", + ) + + @app.post( "/changelog", tags=["changelog"], @@ -866,16 +881,28 @@ async def changelog_endpoint( @app.post( "/breaking", tags=["breaking"], + operation_id="breakingChangesDataContracts", summary="Show compatibility impact between two data contracts.", description=""" - Compare two ODCS data contract YAMLs and classify their backward-compatibility impact. - POST a JSON body with `v1` (source/before) and `v2` (target/after) as YAML strings. +Compare two versions of an ODCS data contract and detect backward-incompatible changes. + +`POST` a JSON body with `v1` (before) and `v2` (after) as YAML strings. A contract that cannot be +parsed is answered with `422`. """, + response_description="The breaking changes detected between the two data contracts.", + responses={ + **AUTHENTICATION_RESPONSES, + 422: { + "description": "One of the two data contracts is not valid YAML or not a valid data contract.", + "model": UnprocessableEntityResponse, + "content": {"application/json": {"example": {"detail": "Invalid YAML: while parsing a block mapping"}}}, + }, + }, ) -async def breaking_endpoint( +def breaking_endpoint( body: ChangelogRequest, api_key: Annotated[str | None, Depends(api_key_header)] = None, -): +) -> BreakingChangesResponse: check_api_key(api_key) with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f1: @@ -887,7 +914,7 @@ async def breaking_endpoint( try: result = DataContract(data_contract_file=v1_path).breaking(DataContract(data_contract_file=v2_path)) - return result + return BreakingChangesResponse(summary=result.summary, entries=result.entries, is_breaking=result.is_breaking) except yaml.YAMLError as e: raise HTTPException(status_code=422, detail=f"Invalid YAML: {e}") except pydantic.ValidationError as e: diff --git a/datacontract/breaking/detector.py b/datacontract/breaking/detector.py index 00dd7ee50..f62125288 100644 --- a/datacontract/breaking/detector.py +++ b/datacontract/breaking/detector.py @@ -1,3 +1,4 @@ +from collections import defaultdict from collections.abc import Iterable from datacontract.breaking.rules import DEFAULT_RULES, BreakingChangeRule @@ -21,33 +22,29 @@ def __init__(self, rules: Iterable[BreakingChangeRule] = DEFAULT_RULES): def detect(self, changelog: ChangelogResult) -> BreakingChangeResult: entries = [self._classify(entry) for entry in changelog.entries] - summary = [self._summarize(entry, entries) for entry in changelog.summary] + entries_by_prefix = defaultdict(list) + for entry in entries: + prefix = "" + for segment in entry.path.split("."): + prefix = f"{prefix}.{segment}" if prefix else segment + entries_by_prefix[prefix].append(entry) + summary = [self._summarize(entry, entries_by_prefix.get(entry.path, [])) for entry in changelog.summary] return BreakingChangeResult(v1=changelog.v1, v2=changelog.v2, summary=summary, entries=entries) def _classify(self, entry: ChangelogEntry) -> BreakingChangeEntry: - matches = [match for rule in self._rules if (match := rule.evaluate(entry)) is not None] - if not matches: - raise ValueError(f"No breaking-change rule classified {entry.path}") - highest_priority = max(self._priority(match.rule_id) for match in matches) - highest = [match for match in matches if self._priority(match.rule_id) == highest_priority] - if len({match.rule_id for match in highest}) > 1: - raise ValueError(f"Ambiguous breaking-change rules for {entry.path}: {highest}") - evaluation = highest[0] - return BreakingChangeEntry( - path=entry.path, - change_type=entry.type, - level=evaluation.level, - message=evaluation.message, - rule_id=evaluation.rule_id, - old_value=entry.old_value, - new_value=entry.new_value, - ) - - def _priority(self, rule_id: str) -> int: for rule in self._rules: - if rule.rule_id == rule_id: - return rule.priority - raise ValueError(f"Unknown rule {rule_id}") + evaluation = rule.evaluate(entry) + if evaluation is not None: + return BreakingChangeEntry( + path=entry.path, + change_type=entry.type, + level=evaluation.level, + message=evaluation.message, + rule_id=evaluation.rule_id, + old_value=entry.old_value, + new_value=entry.new_value, + ) + raise ValueError(f"No breaking-change rule classified {entry.path}") @staticmethod def _summarize(summary_entry: ChangelogEntry, entries: list[BreakingChangeEntry]) -> BreakingChangeEntry: diff --git a/datacontract/breaking/rules.py b/datacontract/breaking/rules.py index 7f6984ff5..df9125703 100644 --- a/datacontract/breaking/rules.py +++ b/datacontract/breaking/rules.py @@ -1,7 +1,6 @@ -import ast from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any +from datetime import date from datacontract.model.breaking import BreakingChangeLevel from datacontract.model.changelog import ChangelogEntry, ChangelogType @@ -15,7 +14,6 @@ class RuleEvaluation: class BreakingChangeRule(ABC): - priority: int = 0 rule_id: str @abstractmethod @@ -24,7 +22,6 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: class SchemaRemovedRule(BreakingChangeRule): - priority = 90 rule_id = "schema-removed" def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: @@ -35,7 +32,6 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: class FieldRemovedRule(BreakingChangeRule): - priority = 90 rule_id = "field-removed" def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: @@ -47,7 +43,6 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: class RequiredChangedRule(BreakingChangeRule): - priority = 100 rule_id = "required-changed" def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: @@ -69,7 +64,6 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: class TypeChangedRule(BreakingChangeRule): - priority = 80 rule_id = "type-changed" def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: @@ -85,7 +79,6 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: class UniqueConstraintRule(BreakingChangeRule): - priority = 75 rule_id = "unique-constraint-changed" def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: @@ -103,7 +96,6 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: class KeyConstraintRule(BreakingChangeRule): - priority = 70 rule_id = "key-constraint-changed" def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: @@ -112,29 +104,7 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: return RuleEvaluation(self.rule_id, BreakingChangeLevel.WARNING, _change_message("key constraint", entry)) -class EnumConstraintRule(BreakingChangeRule): - priority = 65 - rule_id = "enum-constraint-changed" - - def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: - if not entry.path.endswith(".enum"): - return None - old = _parse_sequence(entry.old_value) - new = _parse_sequence(entry.new_value) - if old is not None and new is not None: - if set(old) - set(new): - level = BreakingChangeLevel.ERROR - elif set(new) - set(old): - level = BreakingChangeLevel.INFO - else: - level = BreakingChangeLevel.INFO - else: - level = BreakingChangeLevel.WARNING - return RuleEvaluation(self.rule_id, level, _change_message("enum constraint", entry)) - - class ValidationConstraintRule(BreakingChangeRule): - priority = 60 rule_id = "validation-constraint-changed" _suffixes = ( ".pattern", @@ -161,7 +131,6 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: class MetadataFallbackRule(BreakingChangeRule): - priority = -100 rule_id = "metadata-or-unknown-change" def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation: @@ -173,7 +142,7 @@ def _is_schema_property_path(segments: list[str]) -> bool: properties_index = segments.index("properties") except ValueError: return False - return segments[0] == "schema" and properties_index + 1 < len(segments) + return segments[0] == "schema" and properties_index == len(segments) - 2 def _parse_bool(value: str | None) -> bool | None: @@ -190,20 +159,14 @@ def _parse_bool(value: str | None) -> bool | None: def _parse_number(value: str | None) -> float | None: if value is None: return None + normalized = value.strip() try: - return float(value.strip()) + return float(normalized) except ValueError: - return None - - -def _parse_sequence(value: str | None) -> list[Any] | None: - if value is None: - return None - try: - parsed = ast.literal_eval(value) - except (SyntaxError, ValueError): - return None - return parsed if isinstance(parsed, list) else None + try: + return float(date.fromisoformat(normalized).toordinal()) + except ValueError: + return None def _is_tightening(path: str, old: float, new: float) -> bool: @@ -229,7 +192,6 @@ def _change_message(subject: str, entry: ChangelogEntry) -> str: TypeChangedRule(), UniqueConstraintRule(), KeyConstraintRule(), - EnumConstraintRule(), ValidationConstraintRule(), MetadataFallbackRule(), ) diff --git a/docs/docs/compare-contract-versions.md b/docs/docs/compare-contract-versions.md new file mode 100644 index 000000000..13f687a3b --- /dev/null +++ b/docs/docs/compare-contract-versions.md @@ -0,0 +1,56 @@ +--- +sidebar_position: 8 +title: "Compare contract versions" +description: "Describe differences and classify backward-compatibility impact between two ODCS data contracts." +--- + +# Compare contract versions + +Use `datacontract changelog` to describe the differences between two versions of a contract, or use `datacontract breaking` to classify their backward-compatibility impact. + +## Changelog + +Use `datacontract changelog` to compare the source contract (`v1`) with the target contract (`v2`) and report the changes between them: + +```bash +datacontract changelog v1.odcs.yaml v2.odcs.yaml +``` + +See the generated [changelog command reference](./commands/changelog.md) for all options. + +## Breaking changes + +Use `datacontract breaking` when a contract change must be checked for backward compatibility. The command compares the source contract (`v1`) with the target contract (`v2`) and preserves the detailed changelog while adding a severity classification. + +```bash +datacontract breaking v1.odcs.yaml v2.odcs.yaml +``` + +See the generated [breaking command reference](./commands/breaking.md) for all options. + +### Severity levels + +- **ERROR** - a backward-incompatible change. The command exits with status `1`. +- **WARNING** - a potentially incompatible change that requires review. The command exits with status `0`. +- **INFO** - informational or currently unclassified metadata. The command exits with status `0`. + +The result is breaking only when at least one detailed entry has severity `ERROR`. + +### Initial compatibility rules + +The first ODCS implementation treats schema and property removals, requiredness tightening, type changes, and uniqueness tightening as errors. Primary-key changes and changes to validation constraints whose direction cannot be proven are warnings. Additions, relaxed constraints, descriptions, tags, business names, custom properties, and unrecognized changes are informational unless a more specific rule applies. + +Every detailed changelog entry receives exactly one classification. Unknown fields use the informational fallback so that introducing a new ODCS field does not make detection fail. + +### API + +The same result is available from `POST /breaking`, using the same JSON request shape as `POST /changelog`: + +```json +{ + "v1": "", + "v2": "" +} +``` + +The response includes `summary`, `entries`, and an `is_breaking` boolean. The endpoint returns HTTP `200` for a valid comparison even when `is_breaking` is `true`; invalid YAML or contracts return HTTP `422`. \ No newline at end of file diff --git a/docs/docs/schema.md b/docs/docs/schema.md index c355cb6b3..8c27a2ab3 100644 --- a/docs/docs/schema.md +++ b/docs/docs/schema.md @@ -28,7 +28,6 @@ datacontract test --checks schema datacontract.yaml | `logicalTypeOptions.minimum` / `maximum` | property | Value within bounds (inclusive) | | `logicalTypeOptions.exclusiveMinimum` / `exclusiveMaximum` | property | Value within bounds (exclusive) | | `logicalTypeOptions.pattern` | property | Value matches the regular expression | -| `logicalTypeOptions.enum` | property | Value is one of the listed values | | `quality` | schema, property | See [Define your Quality Rules](./quality-rules/index.md) | A contract that uses all of them: diff --git a/docs/docs/testing/api.md b/docs/docs/testing/api.md index 721e4fc29..280124afe 100644 --- a/docs/docs/testing/api.md +++ b/docs/docs/testing/api.md @@ -1,5 +1,5 @@ --- -sidebar_position: 11 +sidebar_position: 10 title: "HTTP API" description: "Create a data contract from a JSON HTTP API and test the responses against it (GET requests only)." --- diff --git a/docs/docs/testing/bigquery.md b/docs/docs/testing/bigquery.md index 926b727a1..f9e4b62b8 100644 --- a/docs/docs/testing/bigquery.md +++ b/docs/docs/testing/bigquery.md @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 8 title: "Google BigQuery" description: "Create a data contract from your BigQuery tables and test the actual data against it — in about 5 minutes." --- diff --git a/docs/docs/testing/breaking-changes.md b/docs/docs/testing/breaking-changes.md deleted file mode 100644 index dce3aefd7..000000000 --- a/docs/docs/testing/breaking-changes.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -sidebar_position: 7 -title: "Detect breaking changes" -description: "Compare two ODCS data contracts and classify backward-compatibility impact." ---- - -# Detect breaking changes - -Use `datacontract breaking` when a contract change must be checked for backward compatibility. The command compares the source contract (`v1`) with the target contract (`v2`) and preserves the detailed changelog while adding a severity classification. - -```bash -datacontract breaking v1.odcs.yaml v2.odcs.yaml -``` - -See the generated [breaking command reference](../commands/breaking.md) for all options. - -## Severity levels - -- **ERROR** — a backward-incompatible change. The command exits with status `1`. -- **WARNING** — a potentially incompatible change that requires review. The command exits with status `0`. -- **INFO** — informational or currently unclassified metadata. The command exits with status `0`. - -The result is breaking only when at least one detailed entry has severity `ERROR`. - -## Initial compatibility rules - -The first ODCS implementation treats schema and property removals, requiredness tightening, type changes, uniqueness tightening, and enum narrowing as errors. Primary-key changes and changes to validation constraints whose direction cannot be proven are warnings. Additions, relaxed constraints, descriptions, tags, business names, custom properties, and unrecognized changes are informational unless a more specific rule applies. - -Every detailed changelog entry receives exactly one classification. Unknown fields use the informational fallback so that introducing a new ODCS field does not make detection fail. - -## API - -The same result is available from `POST /breaking`, using the same JSON request shape as `POST /changelog`: - -```json -{ - "v1": "", - "v2": "" -} -``` - -The response includes `summary`, `entries`, and an `is_breaking` boolean. The endpoint returns HTTP `200` for a valid comparison even when `is_breaking` is `true`; invalid YAML or contracts return HTTP `422`. diff --git a/docs/docs/testing/dataframe.md b/docs/docs/testing/dataframe.md index c1f58015e..755364ca3 100644 --- a/docs/docs/testing/dataframe.md +++ b/docs/docs/testing/dataframe.md @@ -1,5 +1,5 @@ --- -sidebar_position: 19 +sidebar_position: 18 title: "Spark DataFrame" description: "Test in-memory Spark DataFrames in a pipeline (programmatic)." --- diff --git a/docs/docs/testing/duckdb.md b/docs/docs/testing/duckdb.md index 8d9ca7429..8ded36968 100644 --- a/docs/docs/testing/duckdb.md +++ b/docs/docs/testing/duckdb.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 7 title: "DuckDB" description: "Test the tables inside a DuckDB database file." --- diff --git a/docs/docs/testing/gcs.md b/docs/docs/testing/gcs.md index 9e23a81e5..d22b8dadd 100644 --- a/docs/docs/testing/gcs.md +++ b/docs/docs/testing/gcs.md @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 9 title: "Google Cloud Storage" description: "Create a data contract from files on Google Cloud Storage and test them against it." --- diff --git a/docs/docs/testing/kafka.md b/docs/docs/testing/kafka.md index 305ec852a..17a6ddb1e 100644 --- a/docs/docs/testing/kafka.md +++ b/docs/docs/testing/kafka.md @@ -1,5 +1,5 @@ --- -sidebar_position: 12 +sidebar_position: 11 title: "Kafka" description: "Create a data contract for a Kafka topic and test the messages against it (experimental)." --- diff --git a/docs/docs/testing/local.md b/docs/docs/testing/local.md index 0a185b729..9b0c51ed9 100644 --- a/docs/docs/testing/local.md +++ b/docs/docs/testing/local.md @@ -1,5 +1,5 @@ --- -sidebar_position: 13 +sidebar_position: 12 title: "Local files" description: "Test local files in Parquet, JSON, CSV, or Delta format — the fastest way to try the CLI, no credentials needed." --- diff --git a/docs/docs/testing/mysql.md b/docs/docs/testing/mysql.md index 56b68bea2..93b380ed7 100644 --- a/docs/docs/testing/mysql.md +++ b/docs/docs/testing/mysql.md @@ -1,5 +1,5 @@ --- -sidebar_position: 15 +sidebar_position: 14 title: "MySQL" description: "Create a data contract from your MySQL tables and test the actual data against it." --- diff --git a/docs/docs/testing/oracle.md b/docs/docs/testing/oracle.md index dd746d1da..c702c8dd9 100644 --- a/docs/docs/testing/oracle.md +++ b/docs/docs/testing/oracle.md @@ -1,5 +1,5 @@ --- -sidebar_position: 16 +sidebar_position: 15 title: "Oracle" description: "Create a data contract from your Oracle tables and test the actual data against it." --- diff --git a/docs/docs/testing/postgres.md b/docs/docs/testing/postgres.md index b96b9413e..ec9f4d3cb 100644 --- a/docs/docs/testing/postgres.md +++ b/docs/docs/testing/postgres.md @@ -1,5 +1,5 @@ --- -sidebar_position: 17 +sidebar_position: 16 title: "Postgres" description: "Create a data contract from your Postgres tables and test the actual data against it — in about 5 minutes." --- diff --git a/docs/docs/testing/snowflake.md b/docs/docs/testing/snowflake.md index 4cd9b21fa..fe1251610 100644 --- a/docs/docs/testing/snowflake.md +++ b/docs/docs/testing/snowflake.md @@ -1,5 +1,5 @@ --- -sidebar_position: 18 +sidebar_position: 17 title: "Snowflake" description: "Create a data contract from your Snowflake tables and test the actual data against it — in about 5 minutes." --- diff --git a/docs/docs/testing/sqlserver.md b/docs/docs/testing/sqlserver.md index 24c6fdbfe..1c1231808 100644 --- a/docs/docs/testing/sqlserver.md +++ b/docs/docs/testing/sqlserver.md @@ -1,5 +1,5 @@ --- -sidebar_position: 14 +sidebar_position: 13 title: "Microsoft SQL Server" description: "Create a data contract from your SQL Server tables and test the actual data against it." --- diff --git a/docs/docs/testing/trino.md b/docs/docs/testing/trino.md index 144b76a7c..47f645c1f 100644 --- a/docs/docs/testing/trino.md +++ b/docs/docs/testing/trino.md @@ -1,5 +1,5 @@ --- -sidebar_position: 20 +sidebar_position: 19 title: "Trino" description: "Create a data contract from your Trino tables and test the actual data against it." --- diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 6c9ff95d2..90cf6cb97 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -1,5 +1,5 @@ -import {themes as prismThemes} from 'prism-react-renderer'; -import type {Config} from '@docusaurus/types'; +import { themes as prismThemes } from 'prism-react-renderer'; +import type { Config } from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) @@ -21,7 +21,7 @@ function withAccessibleTokenColors( const color = entry.style?.color; const replacement = color && replacements[color]; return replacement - ? {...entry, style: {...entry.style, color: replacement}} + ? { ...entry, style: { ...entry.style, color: replacement } } : entry; }), }; @@ -113,7 +113,7 @@ const config: Config = { // Raise the priority of the main hub pages so crawlers can // distinguish them from the long tail of import/export reference pages. createSitemapItems: async (params) => { - const {defaultCreateSitemapItems, ...rest} = params; + const { defaultCreateSitemapItems, ...rest } = params; const items = await defaultCreateSitemapItems(rest); const hubs: Record = { 'https://docs.datacontract.com/': 1.0, @@ -122,7 +122,7 @@ const config: Config = { }; return items.map((item) => { const priority = hubs[item.url]; - return priority ? {...item, priority} : item; + return priority ? { ...item, priority } : item; }); }, }, @@ -160,6 +160,7 @@ const config: Config = { 'testing/index.md', 'testing/*.md', 'schema.md', + 'compare-contract-versions.md', 'quality-rules/index.md', 'quality-rules/*.md', 'service-levels.md', @@ -212,8 +213,8 @@ const config: Config = { themeConfig: { image: 'img/datacontractcli.png', metadata: [ - {name: 'og:type', content: 'website'}, - {name: 'og:site_name', content: 'Data Contract CLI'}, + { name: 'og:type', content: 'website' }, + { name: 'og:site_name', content: 'Data Contract CLI' }, { name: 'keywords', content: @@ -261,16 +262,16 @@ const config: Config = { { title: 'Docs', items: [ - {label: 'What is Data Contract CLI?', to: '/'}, - {label: 'Quickstart', to: '/quickstart'}, - {label: 'Commands', to: '/commands/'}, - {label: 'Release Notes', to: '/release-notes'}, + { label: 'What is Data Contract CLI?', to: '/' }, + { label: 'Quickstart', to: '/quickstart' }, + { label: 'Commands', to: '/commands/' }, + { label: 'Release Notes', to: '/release-notes' }, ], }, { title: 'Community', items: [ - {label: 'Slack', href: 'https://datacontract.com/slack'}, + { label: 'Slack', href: 'https://datacontract.com/slack' }, { label: 'GitHub', href: 'https://github.com/datacontract/datacontract-cli', @@ -284,16 +285,16 @@ const config: Config = { { title: 'More', items: [ - {label: 'datacontract.com', href: 'https://datacontract.com'}, - {label: 'Data Contract Editor', href: 'https://editor.datacontract.com'}, - {label: 'PyPI', href: 'https://pypi.org/project/datacontract-cli/'}, + { label: 'datacontract.com', href: 'https://datacontract.com' }, + { label: 'Data Contract Editor', href: 'https://editor.datacontract.com' }, + { label: 'PyPI', href: 'https://pypi.org/project/datacontract-cli/' }, ], }, { title: 'Legal', items: [ - {label: 'Legal Notice', href: 'https://entropy-data.com/legal-notice'}, - {label: 'Privacy Policy', href: 'https://entropy-data.com/privacy-policy'}, + { label: 'Legal Notice', href: 'https://entropy-data.com/legal-notice' }, + { label: 'Privacy Policy', href: 'https://entropy-data.com/privacy-policy' }, ], }, ], diff --git a/tests/test_api.py b/tests/test_api.py index 38e664a3a..ce757a98d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -75,6 +75,8 @@ def test_breaking(): response = client.post(url="/breaking", json={"v1": v1, "v2": v2}) assert response.status_code == 200 data = response.json() + assert "v1" not in data + assert "v2" not in data assert data["is_breaking"] is True assert "summary" in data assert "entries" in data diff --git a/tests/test_breaking.py b/tests/test_breaking.py index 5c7eaeb9a..19d76dd1c 100644 --- a/tests/test_breaking.py +++ b/tests/test_breaking.py @@ -1,12 +1,17 @@ -import pytest from typer.testing import CliRunner from datacontract.breaking.detector import BreakingChangeDetector from datacontract.breaking.rules import ( BreakingChangeRule, + FieldRemovedRule, + KeyConstraintRule, + MetadataFallbackRule, RequiredChangedRule, RuleEvaluation, + SchemaRemovedRule, TypeChangedRule, + UniqueConstraintRule, + ValidationConstraintRule, ) from datacontract.cli import app from datacontract.data_contract import DataContract @@ -49,6 +54,124 @@ def test_type_change_is_error(): assert result.level == BreakingChangeLevel.ERROR +def test_removing_schema_is_breaking(): + result = SchemaRemovedRule().evaluate(_entry("schema.orders", ChangelogType.removed)) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + assert result.message == "Removed schema orders" + + +def test_removing_property_metadata_is_not_property_removal(): + result = FieldRemovedRule().evaluate( + _entry("schema.orders.properties.customer_id.description", ChangelogType.removed, "old description") + ) + assert result is None + + +def test_removing_property_is_breaking(): + result = FieldRemovedRule().evaluate( + _entry("schema.orders.properties.customer_id", ChangelogType.removed, "string") + ) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + assert result.message == "Removed property customer_id" + + +def test_tightening_uniqueness_is_breaking(): + result = UniqueConstraintRule().evaluate( + _entry("schema.orders.properties.order_id.unique", ChangelogType.updated, "False", "True") + ) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + + +def test_changing_primary_key_is_warning(): + result = KeyConstraintRule().evaluate( + _entry("schema.orders.properties.order_id.primaryKey", ChangelogType.updated, "True", "False") + ) + assert result is not None + assert result.level == BreakingChangeLevel.WARNING + + +def test_tightening_validation_constraint_is_breaking(): + result = ValidationConstraintRule().evaluate( + _entry("schema.orders.properties.customer_id.minLength", ChangelogType.updated, "5", "10") + ) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + + +def test_tightening_date_validation_constraint_is_breaking(): + result = ValidationConstraintRule().evaluate( + _entry( + "schema.orders.properties.order_date.logicalTypeOptions.minimum", + ChangelogType.updated, + "2024-01-01", + "2024-02-01", + ) + ) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + + +def test_relaxing_date_minimum_constraint_is_info(): + result = ValidationConstraintRule().evaluate( + _entry( + "schema.orders.properties.order_date.logicalTypeOptions.minimum", + ChangelogType.updated, + "2024-02-01", + "2024-01-01", + ) + ) + assert result is not None + assert result.level == BreakingChangeLevel.INFO + + +def test_tightening_date_maximum_constraint_is_breaking(): + result = ValidationConstraintRule().evaluate( + _entry( + "schema.orders.properties.order_date.logicalTypeOptions.maximum", + ChangelogType.updated, + "2024-02-01", + "2024-01-01", + ) + ) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + + +def test_relaxing_date_validation_constraint_is_info(): + result = ValidationConstraintRule().evaluate( + _entry( + "schema.orders.properties.order_date.logicalTypeOptions.maximum", + ChangelogType.updated, + "2024-01-01", + "2024-02-01", + ) + ) + assert result is not None + assert result.level == BreakingChangeLevel.INFO + + +def test_invalid_date_validation_constraint_is_warning(): + result = ValidationConstraintRule().evaluate( + _entry( + "schema.orders.properties.order_date.logicalTypeOptions.minimum", + ChangelogType.updated, + "not-a-date", + "2024-01-01", + ) + ) + assert result is not None + assert result.level == BreakingChangeLevel.WARNING + + +def test_unknown_change_is_info(): + result = MetadataFallbackRule().evaluate(_entry("description.purpose", ChangelogType.updated, "old", "new")) + assert result.level == BreakingChangeLevel.INFO + assert result.message == "Changed contract at description.purpose from 'old' to 'new'" + + def test_unmatched_entry_uses_info_fallback(): changelog = ChangelogResult( v1="v1", @@ -108,22 +231,22 @@ def test_cli_shows_full_severity_range_for_mixed_changes(): assert "[ 4 Error ] [ 1 Warning ] [ 3 Info ]" in result.output -def test_detector_rejects_ambiguous_rules(): +def test_detector_uses_first_matching_rule(): class FirstRule(BreakingChangeRule): - priority = 10 rule_id = "first" def evaluate(self, entry): return RuleEvaluation(self.rule_id, BreakingChangeLevel.INFO, "first") class SecondRule(BreakingChangeRule): - priority = 10 rule_id = "second" def evaluate(self, entry): return RuleEvaluation(self.rule_id, BreakingChangeLevel.WARNING, "second") - with pytest.raises(ValueError, match="Ambiguous"): - BreakingChangeDetector((FirstRule(), SecondRule())).detect( - ChangelogResult(v1="v1", v2="v2", entries=[_entry("field", ChangelogType.updated)]) - ) + result = BreakingChangeDetector((FirstRule(), SecondRule())).detect( + ChangelogResult(v1="v1", v2="v2", entries=[_entry("field", ChangelogType.updated)]) + ) + + assert result.entries[0].rule_id == "first" + assert result.entries[0].level == BreakingChangeLevel.INFO From 52fea4df2a654f6a9fe6f0fe0a575e59615d2e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:18:31 +0200 Subject: [PATCH 05/17] Grade the removal of a nested property as breaking --- datacontract/breaking/rules.py | 8 ++------ tests/test_breaking.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/datacontract/breaking/rules.py b/datacontract/breaking/rules.py index df9125703..fbeee4bf7 100644 --- a/datacontract/breaking/rules.py +++ b/datacontract/breaking/rules.py @@ -38,7 +38,7 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: segments = entry.path.split(".") if entry.type != ChangelogType.removed or not _is_schema_property_path(segments): return None - property_name = segments[segments.index("properties") + 1] + property_name = segments[-1] return RuleEvaluation(self.rule_id, BreakingChangeLevel.ERROR, f"Removed property {property_name}") @@ -138,11 +138,7 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation: def _is_schema_property_path(segments: list[str]) -> bool: - try: - properties_index = segments.index("properties") - except ValueError: - return False - return segments[0] == "schema" and properties_index == len(segments) - 2 + return len(segments) >= 3 and segments[0] == "schema" and segments[-2] == "properties" def _parse_bool(value: str | None) -> bool | None: diff --git a/tests/test_breaking.py b/tests/test_breaking.py index 19d76dd1c..ed89f4fbe 100644 --- a/tests/test_breaking.py +++ b/tests/test_breaking.py @@ -77,6 +77,22 @@ def test_removing_property_is_breaking(): assert result.message == "Removed property customer_id" +def test_removing_nested_property_is_breaking(): + result = FieldRemovedRule().evaluate( + _entry("schema.orders.properties.customer.properties.email", ChangelogType.removed, "string") + ) + assert result is not None + assert result.level == BreakingChangeLevel.ERROR + assert result.message == "Removed property email" + + +def test_removing_nested_property_metadata_is_not_property_removal(): + result = FieldRemovedRule().evaluate( + _entry("schema.orders.properties.customer.properties.email.description", ChangelogType.removed, "old") + ) + assert result is None + + def test_tightening_uniqueness_is_breaking(): result = UniqueConstraintRule().evaluate( _entry("schema.orders.properties.order_id.unique", ChangelogType.updated, "False", "True") From aec84fd1266759cde619d423b9d187dd1e1d26fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:18:40 +0200 Subject: [PATCH 06/17] Grade an added optional column as informational --- datacontract/breaking/rules.py | 7 +++-- .../changelog/breaking/warning_only_v1.yaml | 4 ++- .../changelog/breaking/warning_only_v2.yaml | 4 ++- tests/test_breaking.py | 30 +++++++++++++++++-- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/datacontract/breaking/rules.py b/datacontract/breaking/rules.py index fbeee4bf7..b2f6526a5 100644 --- a/datacontract/breaking/rules.py +++ b/datacontract/breaking/rules.py @@ -52,7 +52,8 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: new = _parse_bool(entry.new_value) if entry.type == ChangelogType.added and new is True: level = BreakingChangeLevel.ERROR - elif entry.type == ChangelogType.removed or (old is True and new is False): + elif entry.type == ChangelogType.removed or new is False: + # Dropping the requirement, or writing out the optional default, only loosens the contract. level = BreakingChangeLevel.INFO elif entry.type == ChangelogType.updated and old is False and new is True: level = BreakingChangeLevel.ERROR @@ -71,7 +72,9 @@ def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: return None if entry.type == ChangelogType.updated: level = BreakingChangeLevel.ERROR - elif entry.type in (ChangelogType.added, ChangelogType.removed): + elif entry.type == ChangelogType.added: + level = BreakingChangeLevel.INFO + elif entry.type == ChangelogType.removed: level = BreakingChangeLevel.WARNING else: return None diff --git a/tests/fixtures/changelog/breaking/warning_only_v1.yaml b/tests/fixtures/changelog/breaking/warning_only_v1.yaml index 6cae5530a..1c71138f9 100644 --- a/tests/fixtures/changelog/breaking/warning_only_v1.yaml +++ b/tests/fixtures/changelog/breaking/warning_only_v1.yaml @@ -9,4 +9,6 @@ schema: properties: - name: order_id logicalType: string - required: true \ No newline at end of file + required: true + logicalTypeOptions: + pattern: "^ORD-[0-9]+$" diff --git a/tests/fixtures/changelog/breaking/warning_only_v2.yaml b/tests/fixtures/changelog/breaking/warning_only_v2.yaml index 422d6b98d..c4d686d51 100644 --- a/tests/fixtures/changelog/breaking/warning_only_v2.yaml +++ b/tests/fixtures/changelog/breaking/warning_only_v2.yaml @@ -10,6 +10,8 @@ schema: - name: order_id logicalType: string required: true + logicalTypeOptions: + pattern: "^ORD-[0-9]{4}$" - name: region logicalType: string - required: false \ No newline at end of file + required: false diff --git a/tests/test_breaking.py b/tests/test_breaking.py index ed89f4fbe..6b47165be 100644 --- a/tests/test_breaking.py +++ b/tests/test_breaking.py @@ -46,6 +46,30 @@ def test_required_change_to_false_is_info(): assert result.level == BreakingChangeLevel.INFO +def test_adding_required_false_is_info(): + result = RequiredChangedRule().evaluate( + _entry("schema.orders.properties.region.required", ChangelogType.added, None, "False") + ) + assert result is not None + assert result.level == BreakingChangeLevel.INFO + + +def test_adding_a_type_is_info(): + result = TypeChangedRule().evaluate( + _entry("schema.orders.properties.region.logicalType", ChangelogType.added, None, "string") + ) + assert result is not None + assert result.level == BreakingChangeLevel.INFO + + +def test_removing_a_type_is_warning(): + result = TypeChangedRule().evaluate( + _entry("schema.orders.properties.region.logicalType", ChangelogType.removed, "string") + ) + assert result is not None + assert result.level == BreakingChangeLevel.WARNING + + def test_type_change_is_error(): result = TypeChangedRule().evaluate( _entry("schema.orders.properties.order_id.logicalType", ChangelogType.updated, "string", "integer") @@ -231,7 +255,7 @@ def test_data_contract_breaking_reuses_changelog(): def test_cli_warning_only_change_exits_zero_but_shows_warning(): result = runner.invoke(app, ["breaking", WARNING_ONLY_V1, WARNING_ONLY_V2]) assert result.exit_code == 0 - assert "[ 1 Warning ] [ 1 Info ]" in result.output + assert "[ 1 Warning ] [ 2 Info ]" in result.output def test_cli_missing_file_exits_nonzero(): @@ -241,10 +265,10 @@ def test_cli_missing_file_exits_nonzero(): assert "The file 'unknown.yaml' does not exist." in str(result.exception) -def test_cli_shows_full_severity_range_for_mixed_changes(): +def test_cli_exits_nonzero_and_badges_errors_for_mixed_changes(): result = runner.invoke(app, ["breaking", V1, V2]) assert result.exit_code == 1 - assert "[ 4 Error ] [ 1 Warning ] [ 3 Info ]" in result.output + assert "[ 4 Error ] [ 4 Info ]" in result.output def test_detector_uses_first_matching_rule(): From 61ea42a101ee9ed3d7396b752584f62a0f85408e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:18:50 +0200 Subject: [PATCH 07/17] Do not grade changes inside a newly added element --- datacontract/breaking/detector.py | 8 +++++++- tests/test_breaking.py | 27 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/datacontract/breaking/detector.py b/datacontract/breaking/detector.py index f62125288..77b7af3bd 100644 --- a/datacontract/breaking/detector.py +++ b/datacontract/breaking/detector.py @@ -3,7 +3,7 @@ from datacontract.breaking.rules import DEFAULT_RULES, BreakingChangeRule from datacontract.model.breaking import BreakingChangeEntry, BreakingChangeLevel, BreakingChangeResult -from datacontract.model.changelog import ChangelogEntry, ChangelogResult +from datacontract.model.changelog import ChangelogEntry, ChangelogResult, ChangelogType _LEVEL_ORDER = { BreakingChangeLevel.INFO: 0, @@ -22,6 +22,12 @@ def __init__(self, rules: Iterable[BreakingChangeRule] = DEFAULT_RULES): def detect(self, changelog: ChangelogResult) -> BreakingChangeResult: entries = [self._classify(entry) for entry in changelog.entries] + # Nothing inside a newly added element can break a consumer: it did not exist before. + added = {entry.path for entry in entries if entry.change_type == ChangelogType.added} + for entry in entries: + segments = entry.path.split(".") + if any(".".join(segments[:i]) in added for i in range(1, len(segments))): + entry.level = BreakingChangeLevel.INFO entries_by_prefix = defaultdict(list) for entry in entries: prefix = "" diff --git a/tests/test_breaking.py b/tests/test_breaking.py index 6b47165be..4944a3e49 100644 --- a/tests/test_breaking.py +++ b/tests/test_breaking.py @@ -224,6 +224,31 @@ def test_unmatched_entry_uses_info_fallback(): assert not result.is_breaking +def test_required_inside_an_added_schema_is_not_breaking(): + changelog = ChangelogResult( + v1="v1", + v2="v2", + entries=[ + _entry("schema.customers", ChangelogType.added), + _entry("schema.customers.properties.customer_id", ChangelogType.added), + _entry("schema.customers.properties.customer_id.required", ChangelogType.added, None, "True"), + ], + ) + result = BreakingChangeDetector().detect(changelog) + assert all(entry.level == BreakingChangeLevel.INFO for entry in result.entries) + assert not result.is_breaking + + +def test_required_added_to_an_existing_property_is_breaking(): + changelog = ChangelogResult( + v1="v1", + v2="v2", + entries=[_entry("schema.orders.properties.order_id.required", ChangelogType.added, None, "True")], + ) + result = BreakingChangeDetector().detect(changelog) + assert result.is_breaking + + def test_summary_uses_highest_detail_severity(): changelog = ChangelogResult( v1="v1", @@ -268,7 +293,7 @@ def test_cli_missing_file_exits_nonzero(): def test_cli_exits_nonzero_and_badges_errors_for_mixed_changes(): result = runner.invoke(app, ["breaking", V1, V2]) assert result.exit_code == 1 - assert "[ 4 Error ] [ 4 Info ]" in result.output + assert "[ 3 Error ] [ 5 Info ]" in result.output def test_detector_uses_first_matching_rule(): From 8b920aea23caacf5b6cb51410b1fc0f89a9b468c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:01 +0200 Subject: [PATCH 08/17] Assert the full breaking output against a golden file --- .../changelog/golden_breaking_text.txt | 112 ++++++++++++++++++ .../changelog/helper/generate_golden.py | 38 ++++-- tests/test_breaking.py | 30 ++++- 3 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/changelog/golden_breaking_text.txt diff --git a/tests/fixtures/changelog/golden_breaking_text.txt b/tests/fixtures/changelog/golden_breaking_text.txt new file mode 100644 index 000000000..b59936018 --- /dev/null +++ b/tests/fixtures/changelog/golden_breaking_text.txt @@ -0,0 +1,112 @@ +Summary +[ 3 Error ] [ 5 Info ] +╭──────────┬─────────┬───────────────────────────────────────╮ +│ Severity │ Change │ Field │ +├──────────┼─────────┼───────────────────────────────────────┤ +│ INFO │ Added │ schema.customers │ +│ ERROR │ Removed │ schema.orders.properties.customer_id │ +│ ERROR │ Updated │ schema.orders.properties.order_date │ +│ INFO │ Updated │ schema.orders.properties.order_id │ +│ INFO │ Added │ schema.orders.properties.region │ +│ ERROR │ Updated │ schema.orders.properties.total_amount │ +│ INFO │ Updated │ slaProperties.availability │ +│ INFO │ Updated │ version │ +╰──────────┴─────────┴───────────────────────────────────────╯ + +Details +╭──────────┬─────────┬──────────────────────────────────────────────────────────┬────────────────────────────────┬───────────────────────────────┬────────────────────────────────╮ +│ Severity │ Change │ Path │ Old Value │ New Value │ Message │ +├──────────┼─────────┼──────────────────────────────────────────────────────────┼────────────────────────────────┼───────────────────────────────┼────────────────────────────────┤ +│ INFO │ Added │ schema.customers │ │ │ Added contract at │ +│ │ │ │ │ │ schema.customers │ +│ INFO │ Added │ schema.customers.physicalName │ │ customers_tbl │ Added contract at │ +│ │ │ │ │ │ schema.customers.physicalName │ +│ INFO │ Added │ schema.customers.properties │ │ │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties │ +│ INFO │ Added │ schema.customers.properties.country │ │ │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.country.logicalType │ │ string │ Added type at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.country.partitionKeyPosition │ │ 1 │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.country.partitioned │ │ True │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.country.required │ │ False │ Added requiredness at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.created_at │ │ │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.created_at.description │ │ Record creation timestamp │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.created_at.logicalType │ │ timestamp │ Added type at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.created_at.required │ │ True │ Added requiredness at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.customer_id │ │ │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.customer_id.description │ │ Unique order ID │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.customer_id.logicalType │ │ string │ Added type at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.customer_id.primaryKey │ │ True │ Added key constraint at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.customer_id.required │ │ True │ Added requiredness at │ +│ │ │ │ │ │ schema.customers.properties.c… │ +│ INFO │ Added │ schema.customers.properties.date_of_birth │ │ │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.d… │ +│ INFO │ Added │ schema.customers.properties.date_of_birth.classification │ │ restricted │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.d… │ +│ INFO │ Added │ schema.customers.properties.date_of_birth.logicalType │ │ date │ Added type at │ +│ │ │ │ │ │ schema.customers.properties.d… │ +│ INFO │ Added │ schema.customers.properties.date_of_birth.required │ │ False │ Added requiredness at │ +│ │ │ │ │ │ schema.customers.properties.d… │ +│ INFO │ Added │ schema.customers.properties.email │ │ │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.e… │ +│ INFO │ Added │ schema.customers.properties.email.classification │ │ confidential │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.e… │ +│ INFO │ Added │ schema.customers.properties.email.encryptedName │ │ email_encrypt │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.e… │ +│ INFO │ Added │ schema.customers.properties.email.logicalType │ │ string │ Added type at │ +│ │ │ │ │ │ schema.customers.properties.e… │ +│ INFO │ Added │ schema.customers.properties.email.required │ │ True │ Added requiredness at │ +│ │ │ │ │ │ schema.customers.properties.e… │ +│ INFO │ Added │ schema.customers.properties.full_name │ │ │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.f… │ +│ INFO │ Added │ schema.customers.properties.full_name.businessName │ │ Customer Full Name │ Added contract at │ +│ │ │ │ │ │ schema.customers.properties.f… │ +│ INFO │ Added │ schema.customers.properties.full_name.logicalType │ │ string │ Added type at │ +│ │ │ │ │ │ schema.customers.properties.f… │ +│ INFO │ Added │ schema.customers.properties.full_name.required │ │ True │ Added requiredness at │ +│ │ │ │ │ │ schema.customers.properties.f… │ +│ ERROR │ Removed │ schema.orders.properties.customer_id │ │ │ Removed property customer_id │ +│ WARNING │ Removed │ schema.orders.properties.customer_id.logicalType │ string │ │ Removed type at │ +│ │ │ │ │ │ schema.orders.properties.cust… │ +│ INFO │ Removed │ schema.orders.properties.customer_id.required │ True │ │ Removed requiredness at │ +│ │ │ │ │ │ schema.orders.properties.cust… │ +│ ERROR │ Updated │ schema.orders.properties.order_date.logicalType │ string │ date │ Changed type at │ +│ │ │ │ │ │ schema.orders.properties.orde… │ +│ │ │ │ │ │ from 'string' to 'date' │ +│ INFO │ Updated │ schema.orders.properties.order_id.description │ Unique order ID and a rather │ Unique order ID and another │ Changed contract at │ +│ │ │ │ lenghty description that │ rather lenghty description │ schema.orders.properties.orde… │ +│ │ │ │ should be wrapped in the table │ that should be wrapped in the │ from 'Unique order ID and a │ +│ │ │ │ │ table │ rather lenghty description │ +│ │ │ │ │ │ that should be wrapped in the │ +│ │ │ │ │ │ table' to 'Unique order ID and │ +│ │ │ │ │ │ another rather lenghty │ +│ │ │ │ │ │ description that should be │ +│ │ │ │ │ │ wrapped in the table' │ +│ INFO │ Added │ schema.orders.properties.region │ │ │ Added contract at │ +│ │ │ │ │ │ schema.orders.properties.regi… │ +│ INFO │ Added │ schema.orders.properties.region.logicalType │ │ string │ Added type at │ +│ │ │ │ │ │ schema.orders.properties.regi… │ +│ INFO │ Added │ schema.orders.properties.region.required │ │ False │ Added requiredness at │ +│ │ │ │ │ │ schema.orders.properties.regi… │ +│ ERROR │ Updated │ schema.orders.properties.total_amount.required │ False │ True │ Changed requiredness at │ +│ │ │ │ │ │ schema.orders.properties.tota… │ +│ │ │ │ │ │ from 'False' to 'True' │ +│ INFO │ Updated │ slaProperties.availability.value │ 99.9% │ 99.5% │ Changed contract at │ +│ │ │ │ │ │ slaProperties.availability.va… │ +│ │ │ │ │ │ from '99.9%' to '99.5%' │ +│ INFO │ Updated │ version │ 1.0.0 │ 2.0.0 │ Changed contract at version │ +│ │ │ │ │ │ from '1.0.0' to '2.0.0' │ +╰──────────┴─────────┴──────────────────────────────────────────────────────────┴────────────────────────────────┴───────────────────────────────┴────────────────────────────────╯ + diff --git a/tests/fixtures/changelog/helper/generate_golden.py b/tests/fixtures/changelog/helper/generate_golden.py index 18ff2036f..503804bcc 100644 --- a/tests/fixtures/changelog/helper/generate_golden.py +++ b/tests/fixtures/changelog/helper/generate_golden.py @@ -9,6 +9,7 @@ Golden files written: tests/fixtures/changelog/golden_changelog_text.txt + tests/fixtures/changelog/golden_breaking_text.txt After running, review the diff with git and commit if the changes are expected: git diff tests/fixtures/changelog/ @@ -27,26 +28,39 @@ V2 = os.path.normpath(os.path.join(REPO_ROOT, "tests/fixtures/changelog/integration/changelog_integration_v2.yaml")) -def generate(): - # Import here so the script can be run from the repo root with venv activated - from datacontract.data_contract import DataContract - from datacontract.output.text_changelog_results import write_text_changelog_results - - result = DataContract(data_contract_file=V1).changelog(DataContract(data_contract_file=V2)) - +def _render(write, result) -> str: buf = io.StringIO() con = Console(file=buf, width=300, highlight=False, no_color=True) old_stdout = sys.stdout sys.stdout = buf try: - write_text_changelog_results(result, con) + write(result, con) finally: sys.stdout = old_stdout + return buf.getvalue() + + +def generate(): + # Import here so the script can be run from the repo root with venv activated + from datacontract.data_contract import DataContract + from datacontract.output.text_breaking_results import write_text_breaking_results + from datacontract.output.text_changelog_results import write_text_changelog_results + + v1 = DataContract(data_contract_file=V1) + outputs = { + "golden_changelog_text.txt": _render( + write_text_changelog_results, v1.changelog(DataContract(data_contract_file=V2)) + ), + "golden_breaking_text.txt": _render( + write_text_breaking_results, v1.breaking(DataContract(data_contract_file=V2)) + ), + } - text_path = os.path.normpath(os.path.join(FIXTURE_DIR, "golden_changelog_text.txt")) - with open(text_path, "w", encoding="utf-8") as f: - f.write(buf.getvalue()) - print(f"Written: {text_path}") + for name, text in outputs.items(): + text_path = os.path.normpath(os.path.join(FIXTURE_DIR, name)) + with open(text_path, "w", encoding="utf-8") as f: + f.write(text) + print(f"Written: {text_path}") print("\nDone. Review changes with: git diff tests/fixtures/changelog/") diff --git a/tests/test_breaking.py b/tests/test_breaking.py index 4944a3e49..f43b4955c 100644 --- a/tests/test_breaking.py +++ b/tests/test_breaking.py @@ -1,3 +1,8 @@ +import io +import sys +from pathlib import Path + +from rich.console import Console from typer.testing import CliRunner from datacontract.breaking.detector import BreakingChangeDetector @@ -17,12 +22,15 @@ from datacontract.data_contract import DataContract from datacontract.model.breaking import BreakingChangeLevel from datacontract.model.changelog import ChangelogEntry, ChangelogResult, ChangelogType +from datacontract.output.text_breaking_results import write_text_breaking_results V1 = "fixtures/changelog/integration/changelog_integration_v1.yaml" V2 = "fixtures/changelog/integration/changelog_integration_v2.yaml" WARNING_ONLY_V1 = "fixtures/changelog/breaking/warning_only_v1.yaml" WARNING_ONLY_V2 = "fixtures/changelog/breaking/warning_only_v2.yaml" +GOLDEN_TEXT = Path(__file__).parent / "fixtures/changelog/golden_breaking_text.txt" + runner = CliRunner() @@ -263,11 +271,23 @@ def test_summary_uses_highest_detail_severity(): assert result.summary[0].level == BreakingChangeLevel.ERROR -def test_every_detail_entry_is_classified(): - changelog = DataContract(data_contract_file=V1).changelog(DataContract(data_contract_file=V2)) - result = BreakingChangeDetector().detect(changelog) - assert len(result.entries) == len(changelog.entries) - assert all(isinstance(entry.level, BreakingChangeLevel) for entry in result.entries) +def test_golden_output(): + result = DataContract(data_contract_file=V1).breaking(DataContract(data_contract_file=V2)) + assert len(result.entries) == len( + DataContract(data_contract_file=V1).changelog(DataContract(data_contract_file=V2)).entries + ) + buf = io.StringIO() + con = Console(file=buf, width=300, highlight=False, no_color=True) + old_stdout = sys.stdout + sys.stdout = buf + try: + write_text_breaking_results(result, con) + finally: + sys.stdout = old_stdout + assert buf.getvalue() == GOLDEN_TEXT.read_text(encoding="utf-8"), ( + "Breaking-change text output has changed. If intentional, regenerate " + "golden_breaking_text.txt (see tests/fixtures/changelog/helper/generate_golden.py)." + ) def test_data_contract_breaking_reuses_changelog(): From c5a71fb72e94378e2a64a86738199be7c86a144e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:05 +0200 Subject: [PATCH 09/17] Drop the breaking CLI test duplicated in test_breaking.py --- tests/test_cli.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index fb2e4d49a..4c386aa1c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -135,20 +135,6 @@ def test_breaking_help(): assert result.exit_code == 0 -def test_breaking_with_changes_exits_nonzero_and_shows_severity(): - result = runner.invoke( - app, - [ - "breaking", - "fixtures/changelog/integration/changelog_integration_v1.yaml", - "fixtures/changelog/integration/changelog_integration_v2.yaml", - ], - ) - assert result.exit_code == 1 - assert "Severity" in result.output - assert "ERROR" in result.output - - def test_breaking_without_changes_exits_zero(): fixture = "fixtures/changelog/integration/changelog_integration_v1.yaml" result = runner.invoke(app, ["breaking", fixture, fixture]) From e6bc008c0445256d04b5dd5b2d009969ce7db16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:12 +0200 Subject: [PATCH 10/17] Drop the unused BreakingChangeResult helpers and package re-exports --- datacontract/breaking/__init__.py | 4 ---- datacontract/model/breaking.py | 6 ------ 2 files changed, 10 deletions(-) diff --git a/datacontract/breaking/__init__.py b/datacontract/breaking/__init__.py index fe3797223..e69de29bb 100644 --- a/datacontract/breaking/__init__.py +++ b/datacontract/breaking/__init__.py @@ -1,4 +0,0 @@ -from datacontract.breaking.detector import BreakingChangeDetector -from datacontract.breaking.rules import BreakingChangeRule, RuleEvaluation - -__all__ = ["BreakingChangeDetector", "BreakingChangeRule", "RuleEvaluation"] diff --git a/datacontract/model/breaking.py b/datacontract/model/breaking.py index 5fe9f6b69..157f1f1b6 100644 --- a/datacontract/model/breaking.py +++ b/datacontract/model/breaking.py @@ -31,9 +31,3 @@ class BreakingChangeResult(BaseModel): @property def is_breaking(self) -> bool: return any(entry.level == BreakingChangeLevel.ERROR for entry in self.entries) - - def has_changes(self) -> bool: - return bool(self.entries) - - def pretty(self) -> str: - return self.model_dump_json(indent=2) From 74333506466da9c22d77634c57e0ece56f93dd12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:17 +0200 Subject: [PATCH 11/17] Summarize from the prefix index without re-filtering it --- datacontract/breaking/detector.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/datacontract/breaking/detector.py b/datacontract/breaking/detector.py index 77b7af3bd..777c28848 100644 --- a/datacontract/breaking/detector.py +++ b/datacontract/breaking/detector.py @@ -54,12 +54,7 @@ def _classify(self, entry: ChangelogEntry) -> BreakingChangeEntry: @staticmethod def _summarize(summary_entry: ChangelogEntry, entries: list[BreakingChangeEntry]) -> BreakingChangeEntry: - matching = [ - entry - for entry in entries - if entry.path == summary_entry.path or entry.path.startswith(f"{summary_entry.path}.") - ] - if not matching: + if not entries: return BreakingChangeEntry( path=summary_entry.path, change_type=summary_entry.type, @@ -67,7 +62,7 @@ def _summarize(summary_entry: ChangelogEntry, entries: list[BreakingChangeEntry] message=f"Changed contract at {summary_entry.path}", rule_id="summary-fallback", ) - highest = max(matching, key=lambda entry: _LEVEL_ORDER[entry.level]) + highest = max(entries, key=lambda entry: _LEVEL_ORDER[entry.level]) return BreakingChangeEntry( path=summary_entry.path, change_type=summary_entry.type, From 4193d58ce7ff1c7fdaea115322c0fd38703cf031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:31 +0200 Subject: [PATCH 12/17] Add the breaking tag to the OpenAPI spec --- datacontract/api.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/datacontract/api.py b/datacontract/api.py index 6fd56b38d..7b224a0f7 100644 --- a/datacontract/api.py +++ b/datacontract/api.py @@ -324,6 +324,14 @@ def _cli_version() -> str: "url": "https://docs.datacontract.com/commands/changelog", }, }, + { + "name": "breaking", + "description": "Compare two versions of a data contract and grade each change by compatibility impact.", + "externalDocs": { + "description": "Documentation", + "url": "https://docs.datacontract.com/commands/breaking", + }, + }, ], ) From 3f741e4de8f716708fb43058a6ff33da5f9644b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:36 +0200 Subject: [PATCH 13/17] Describe the breaking changes response accurately --- datacontract/api.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/datacontract/api.py b/datacontract/api.py index 7b224a0f7..910c2a965 100644 --- a/datacontract/api.py +++ b/datacontract/api.py @@ -891,13 +891,14 @@ class ChangelogResponse(BaseModel): class BreakingChangesResponse(BaseModel): - """The breaking changes between two versions of a data contract.""" + """Every change between two versions of a data contract, each graded `info`, `warning` or `error`.""" summary: list[BreakingChangeEntry] = Field( - description="One entry per breaking change, rolled up to the level a reader cares about.", + description="One entry per changed element, graded by the most severe change. A property whose type and requiredness " + "both changed appears once.", ) entries: list[BreakingChangeEntry] = Field( - description="Every individual breaking change, with the old and new value.", + description="Every single change with its grade and the old and new value.", ) is_breaking: bool = Field( description="Whether any detected change is classified as an error-level breaking change.", From 5ddebc87b24584c1ffba0c016e595c004357b705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:36 +0200 Subject: [PATCH 14/17] Document POST /breaking and DataContract.breaking() --- docs/docs/api.md | 37 +++++++++++++++++++++++++++++++++++-- docs/docs/python-library.md | 15 ++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index 437d03a6d..b22362045 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -66,9 +66,11 @@ curl -X POST "http://localhost:4242/export?format=sql" \ --data-binary @datacontract.yaml ``` -## Changelog between two contracts +## Comparing two contracts -POST a JSON body with `v1` (before) and `v2` (after) as YAML strings. The response is a JSON object with `summary` and `entries`: +Both comparison endpoints take the same JSON body: `v1` (before) and `v2` (after) as YAML strings. + +`POST /changelog` lists what changed. The response is a JSON object with `summary` (one entry per changed field) and `entries` (one per atomic change): ```bash curl -X POST "http://localhost:4242/changelog" \ @@ -79,6 +81,37 @@ curl -X POST "http://localhost:4242/changelog" \ }' ``` +`POST /breaking` grades those same changes for compatibility impact. It adds a `level` (`info`, `warning` or `error`) and a `rule_id` to every entry, plus a top-level `is_breaking` flag that is `true` when any entry is an `error`: + +```bash +curl -X POST "http://localhost:4242/breaking" \ + -H "Content-Type: application/json" \ + -d '{ + "v1": "'"$(cat v1.odcs.yaml)"'", + "v2": "'"$(cat v2.odcs.yaml)"'" + }' +``` + +```json +{ + "summary": [...], + "entries": [ + { + "path": "schema.orders.properties.order_id.logicalTypeOptions.pattern", + "change_type": "updated", + "level": "warning", + "message": "Changed validation constraint at schema.orders.properties.order_id.logicalTypeOptions.pattern from '^ORD-[0-9]+$' to '^ORD-[0-9]{4}$'", + "rule_id": "validation-constraint-changed", + "old_value": "^ORD-[0-9]+$", + "new_value": "^ORD-[0-9]{4}$" + } + ], + "is_breaking": false +} +``` + +See [Compare contract versions](./compare-contract-versions.md) for the severity levels and the rules behind them. + ## Configure server credentials To connect to a data source, set the required credentials as environment variables **before starting the API** (see [Configuration](./configuration.md)). For example, for Snowflake: diff --git a/docs/docs/python-library.md b/docs/docs/python-library.md index a8970c628..936d0fd4c 100644 --- a/docs/docs/python-library.md +++ b/docs/docs/python-library.md @@ -121,7 +121,9 @@ print(data_contract.export("odcs")) See [Imports](./imports/index.md) for the full list of formats. -## Compare two contracts (changelog) +## Compare two contracts + +`changelog()` lists what changed between two versions: ```python from datacontract.data_contract import DataContract @@ -133,6 +135,17 @@ result = v1.changelog(v2) print(result) ``` +`breaking()` grades those same changes for compatibility impact. Every entry carries a `level` (`info`, `warning` or `error`), and `is_breaking` is `True` when any entry is an error: + +```python +result = v1.breaking(v2) + +for entry in result.entries: + print(entry.level.value, entry.path, entry.message) +``` + +See [Compare contract versions](./compare-contract-versions.md) for the severity levels and the rules behind them. + ## Spark DataFrames and Databricks Pass a `SparkSession` to test in-memory DataFrames (registered as temporary views) or to run inside a Databricks notebook: From 6fe198f3ddae7525cbde1341d6b3b01eb79b2da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:36 +0200 Subject: [PATCH 15/17] Stop committing the generated breaking command page --- docs/docs/commands/breaking.md | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 docs/docs/commands/breaking.md diff --git a/docs/docs/commands/breaking.md b/docs/docs/commands/breaking.md deleted file mode 100644 index 587153dbe..000000000 --- a/docs/docs/commands/breaking.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -sidebar_position: 5 -title: "breaking" -description: "Show compatibility impact between two data contracts." ---- - -# `datacontract breaking` - -{/* AUTOGENERATED from `datacontract --help`: do not edit by hand; regenerate with update_command_docs.py */} - -Show compatibility impact between two data contracts. - -```bash -datacontract breaking [OPTIONS] V1 V2 -``` - -| Argument | Default | Description | -|---|---|---| -| `V1` | required | The location (url, s3 url, or local path) of the source (before) data contract YAML. | -| `V2` | required | The location (url, s3 url, or local path) of the target (after) data contract YAML. | - -| Option | Default | Description | -|---|---|---| -| `--inline-references` / `--no-inline-references` | `--inline-references` | Resolve external references (currently: authoritativeDefinitions\[type in \{definition, semantics\}]) in the contract and inline the fetched content from the configured entropy-data host. | -| `--debug` / `--no-debug` | — | Enable debug logging | - -```bash -datacontract breaking datacontract-v1.yaml datacontract-v2.yaml -``` From bdb748a8eefee87cc871f4c549420f5f023069b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:37 +0200 Subject: [PATCH 16/17] Link the changelog and breaking command pages to their guide --- update_command_docs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/update_command_docs.py b/update_command_docs.py index 1e9c8d14f..ef72c99c2 100644 --- a/update_command_docs.py +++ b/update_command_docs.py @@ -60,6 +60,8 @@ "api": ("Run as a web server", "../api.md"), "publish": ("Publish to Entropy Data", "../entropy-data.md"), "lint": ("Open Data Contract Standard", "../open-data-contract-standard.md"), + "changelog": ("Compare contract versions", "../compare-contract-versions.md"), + "breaking": ("Compare contract versions", "../compare-contract-versions.md"), "import": ("Imports", "../imports/index.md"), "export": ("Exports", "../exports/index.md"), } From 68ea06636d8af979298e636a43078b64c9051900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Tue, 1 Sep 2026 11:19:37 +0200 Subject: [PATCH 17/17] Reference the issue instead of the PR in the changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7435450..63b81669f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- `datacontract breaking` command and `POST /breaking` endpoint for breaking change detection (#1482) +- `datacontract breaking` command and `POST /breaking` endpoint for breaking change detection (#1016) - `datacontract test` checks the ODCS array options `minItems`, `maxItems` and `uniqueItems` (#1514) - `datacontract export odcs` defaults `status` to `draft` when the source DCS contract has no `info.status`