Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Reject duplicate YAML mapping keys instead of silently overwriting contract fields.

## [1.2.5] - 2026-10-12

### Changed
Expand Down
23 changes: 22 additions & 1 deletion datacontract/lint/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,28 @@ class _LaxOpenDataContractStandard(OpenDataContractStandard):
class _SafeLoaderNoTimestamp(yaml.SafeLoader):
"""SafeLoader that keeps dates/timestamps as strings instead of converting to datetime objects."""

pass
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[object, object]:
# Check only explicit keys before SafeLoader expands YAML merge keys.
# An explicit override of an inherited anchor value is valid YAML.
seen = set()
for key_node, _ in node.value:
if key_node.tag == "tag:yaml.org,2002:merge":
continue
key = self.construct_object(key_node, deep=deep)
try:
duplicate = key in seen
seen.add(key)
except TypeError:
# Let SafeLoader report its usual unhashable-key error.
return super().construct_mapping(node, deep=deep)
if duplicate:
raise yaml.constructor.ConstructorError(
"while constructing a mapping",
node.start_mark,
f"found duplicate key {key!r}",
key_node.start_mark,
)
return super().construct_mapping(node, deep=deep)


# Remove the timestamp implicit resolver so dates like 2022-01-15 stay as strings
Expand Down
49 changes: 49 additions & 0 deletions tests/test_resolve_duplicate_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Duplicate contract keys must not silently overwrite schema or quality rules."""

import pytest

from datacontract.data_contract import DataContract
from datacontract.lint.resolve import _to_yaml
from datacontract.model.exceptions import DataContractException


@pytest.mark.parametrize(
"document",
[
"status: active\nstatus: draft\n",
"schema:\n- name: orders\n properties:\n - name: id\n required: true\n required: false\n",
"schema:\n- name: orders\n quality: {type: sql, query: SELECT 1, query: SELECT 2}\n",
],
)
def test_duplicate_mapping_keys_are_rejected(document: str) -> None:
with pytest.raises(DataContractException, match="duplicate key"):
_to_yaml(document)


def test_duplicate_key_error_identifies_source_and_line() -> None:
with pytest.raises(DataContractException) as exc:
_to_yaml("version: 1.0.0\nversion: 2.0.0\n", "contracts/orders.yaml")
assert "contracts/orders.yaml" in str(exc.value)
assert "line 2" in str(exc.value)


def test_yaml_merge_allows_explicit_override() -> None:
parsed = _to_yaml("defaults: &defaults {required: false}\ncolumn: {<<: *defaults, required: true}\n")
assert parsed["column"]["required"] is True


def test_yaml_merge_does_not_hide_duplicate_explicit_keys() -> None:
with pytest.raises(DataContractException, match="duplicate key"):
_to_yaml("defaults: &defaults {required: false}\ncolumn: {<<: *defaults, required: true, required: false}\n")


def test_date_strings_and_distinct_mapping_keys_are_unchanged() -> None:
parsed = _to_yaml("date: 2026-10-13\nfirst: {name: id}\nsecond: {name: id}\n")
assert parsed["date"] == "2026-10-13"
assert parsed["first"] == parsed["second"] == {"name": "id"}


def test_lint_reports_duplicate_keys_as_a_failed_check() -> None:
run = DataContract(data_contract_str="apiVersion: v3.0.2\napiVersion: v3.2.0\n").lint()
assert not run.has_passed()
assert any("duplicate key" in (check.reason or "") for check in run.checks)
Loading