Skip to content

Commit 9c05988

Browse files
Sourabhchrs93claude
andcommitted
fix: sanitize unused-but-required manifest fields with sentinel values
The previous fallback in `_try_parse_manifest` removed `disabled` from the input dict on retry, but `ManifestV12.disabled` is declared `Field(...)` (required), so Pydantic still raised "Field required". Production hit the same class of failure on the top-level `docs` map when a dbt Cloud manifest emitted a `__overview__` doc entry without `resource_type`. Replace `_strip_unused_fields` with `_sanitize_unused_fields`, which overwrites unused fields with safe sentinel values (`{}`). Add `docs` to the sanitized set. Both fields are unread by any downstream wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7c9043e commit 9c05988

2 files changed

Lines changed: 73 additions & 12 deletions

File tree

src/vendor/dbt_artifacts_parser/parser.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,14 @@
4545

4646
logger = logging.getLogger(__name__)
4747

48-
# Fields with strict discriminated unions that break on dbt schema changes
49-
# but are not consumed by downstream wrappers
50-
_UNUSED_STRICT_FIELDS = {"disabled"}
48+
# Fields with strict discriminated unions or required nested shapes that
49+
# frequently drift as dbt evolves its schema, but are not consumed by any
50+
# downstream wrapper. Mapped to safe sentinel values used when the original
51+
# manifest fails validation.
52+
_UNUSED_STRICT_FIELDS = {
53+
"disabled": {},
54+
"docs": {},
55+
}
5156

5257
# Regex to extract manifest version number from schema URL
5358
_MANIFEST_VERSION_RE = re.compile(r"https://schemas\.getdbt\.com/dbt/manifest/v(\d+)\.json")
@@ -85,23 +90,27 @@ def parse_catalog_v1(catalog: dict) -> CatalogV1:
8590
#
8691
# manifest
8792
#
88-
def _strip_unused_fields(manifest: dict) -> dict:
89-
"""Remove fields that have strict discriminated unions but are unused downstream.
90-
91-
These fields (e.g. `disabled`) use complex Pydantic unions that break when
92-
dbt Cloud changes its schema, but our wrappers never read them.
93+
def _sanitize_unused_fields(manifest: dict) -> dict:
94+
"""Overwrite unused-but-required fields with safe sentinel values.
95+
96+
Some manifest fields (e.g. `disabled`, top-level `docs`) are declared as
97+
required on the Pydantic model but are never read by downstream wrappers.
98+
When dbt Cloud changes their shape — or omits required sub-fields like
99+
`resource_type` — validation fails. Removing the keys does not help
100+
because the model still raises "Field required". Overwriting with an
101+
empty dict satisfies the type contract without affecting consumers.
93102
"""
94-
return {k: v for k, v in manifest.items() if k not in _UNUSED_STRICT_FIELDS}
103+
return {**manifest, **_UNUSED_STRICT_FIELDS}
95104

96105

97106
def _try_parse_manifest(manifest: dict, model_class):
98-
"""Attempt to parse manifest, falling back to stripping unused fields on failure."""
107+
"""Attempt to parse manifest, falling back to sanitizing unused fields on failure."""
99108
try:
100109
return model_class(**manifest)
101110
except Exception:
102-
stripped = _strip_unused_fields(manifest)
111+
sanitized = _sanitize_unused_fields(manifest)
103112
try:
104-
return model_class(**stripped)
113+
return model_class(**sanitized)
105114
except Exception:
106115
raise
107116

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Tests for manifest parser fallback that sanitizes unused-but-required fields."""
2+
import copy
3+
import json
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from vendor.dbt_artifacts_parser.parser import parse_manifest
9+
from vendor.dbt_artifacts_parser.parsers.manifest.manifest_v12 import ManifestV12
10+
11+
FIXTURE_PATH = Path(__file__).parent.parent / "data" / "manifest_v12.json"
12+
13+
14+
@pytest.fixture()
15+
def valid_manifest() -> dict:
16+
with FIXTURE_PATH.open() as f:
17+
return json.load(f)
18+
19+
20+
class TestManifestSanitize:
21+
def test_valid_manifest_parses(self, valid_manifest):
22+
manifest = parse_manifest(valid_manifest)
23+
assert isinstance(manifest, ManifestV12)
24+
25+
def test_docs_entry_missing_resource_type_is_sanitized(self, valid_manifest):
26+
broken = copy.deepcopy(valid_manifest)
27+
del broken["docs"]["doc.dbt.__overview__"]["resource_type"]
28+
29+
manifest = parse_manifest(broken)
30+
31+
assert isinstance(manifest, ManifestV12)
32+
assert manifest.docs == {}
33+
34+
def test_disabled_field_missing_is_sanitized(self, valid_manifest):
35+
broken = copy.deepcopy(valid_manifest)
36+
del broken["disabled"]
37+
38+
manifest = parse_manifest(broken)
39+
40+
assert isinstance(manifest, ManifestV12)
41+
assert manifest.disabled == {}
42+
43+
def test_both_unused_fields_broken_is_sanitized(self, valid_manifest):
44+
broken = copy.deepcopy(valid_manifest)
45+
del broken["docs"]["doc.dbt.__overview__"]["resource_type"]
46+
del broken["disabled"]
47+
48+
manifest = parse_manifest(broken)
49+
50+
assert isinstance(manifest, ManifestV12)
51+
assert manifest.docs == {}
52+
assert manifest.disabled == {}

0 commit comments

Comments
 (0)