From fe1ea6b829f62805930ba96a8daf7f39228cfee7 Mon Sep 17 00:00:00 2001 From: Sourabh Chourasia Date: Wed, 8 Jul 2026 18:08:39 +0530 Subject: [PATCH] fix: [AI-7435] accept dbt 2.0 `reused` run status in run_results v6 parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbt 2.0 emits `status="reused"` for unchanged models. The `run_results` v6 `Status` enum only knew `success`/`error`/`skipped`/`partial_success`, so `Result` validation raised `ValidationError` and the ingestion worker silently dropped the entire `run_results.json` — run status/timing never persisted while the UI still showed a clean successful sync. - Add `reused` to `Status` and make it a `str, Enum`. - Add `Status._missing_` so future unknown dbt statuses surface as real members instead of failing validation (forward-compatibility). - Reorder `Result.status` union to `Union[Status1, Status2, Status]` so the now permissive run `Status` is tried last, keeping test/freshness statuses (`pass`/`fail`/`warn`/`runtime error`) resolving via the strict enums. - Add regression tests covering `reused`, known statuses, unknown-future status, test/freshness statuses, and full-file `parse_run_results`. - Bump version to `0.3.2` (`0.3.1` already released without this fix). Co-Authored-By: Claude Opus 4.8 (1M context) --- setup.py | 2 +- src/datapilot/__init__.py | 2 +- .../parsers/run_results/run_results_v6.py | 16 +++- tests/test_vendor/test_run_results_v6.py | 76 +++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 tests/test_vendor/test_run_results_v6.py diff --git a/setup.py b/setup.py index 8060b18..f405d8c 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name="altimate-datapilot-cli", - version="0.3.1", + version="0.3.2", license="MIT", description="Assistant for Data Teams", long_description="{}\n{}".format( diff --git a/src/datapilot/__init__.py b/src/datapilot/__init__.py index 260c070..f9aa3e1 100644 --- a/src/datapilot/__init__.py +++ b/src/datapilot/__init__.py @@ -1 +1 @@ -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/src/vendor/dbt_artifacts_parser/parsers/run_results/run_results_v6.py b/src/vendor/dbt_artifacts_parser/parsers/run_results/run_results_v6.py index c48c71b..5b565ae 100644 --- a/src/vendor/dbt_artifacts_parser/parsers/run_results/run_results_v6.py +++ b/src/vendor/dbt_artifacts_parser/parsers/run_results/run_results_v6.py @@ -25,11 +25,23 @@ class Metadata(BaseParserModel): env: Optional[dict[str, str]] = None -class Status(Enum): +class Status(str, Enum): success = "success" error = "error" skipped = "skipped" partial_success = "partial success" + reused = "reused" + + @classmethod + def _missing_(cls, value): + # Forward-compatibility: dbt periodically introduces new run statuses + # (e.g. "reused" in dbt 2.0). Surface unknown values as real members so + # downstream `.value` access keeps working instead of failing validation + # and silently dropping the entire run_results.json. + member = str.__new__(cls, value) + member._name_ = str(value) + member._value_ = value + return member class Status1(Enum): @@ -74,7 +86,7 @@ class Result(BaseParserModel): model_config = ConfigDict( extra="allow", ) - status: Union[Status, Status1, Status2] + status: Union[Status1, Status2, Status] timing: list[TimingItem] thread_id: str execution_time: float diff --git a/tests/test_vendor/test_run_results_v6.py b/tests/test_vendor/test_run_results_v6.py new file mode 100644 index 0000000..778db49 --- /dev/null +++ b/tests/test_vendor/test_run_results_v6.py @@ -0,0 +1,76 @@ +"""Tests for run_results v6 parser, specifically the resilient run `Status` enum. + +Regression coverage for dbt 2.0 emitting ``status="reused"`` (and future unknown +statuses), which previously raised a ``ValidationError`` and caused the entire +run_results.json to be silently dropped during ingestion. +""" +from vendor.dbt_artifacts_parser.parser import parse_run_results +from vendor.dbt_artifacts_parser.parsers.run_results.run_results_v6 import Result, Status + +V6_SCHEMA = "https://schemas.getdbt.com/dbt/run-results/v6.json" + + +def _result(status: str, unique_id: str) -> dict: + return { + "status": status, + "timing": [], + "thread_id": "Thread-1", + "execution_time": 0.1, + "adapter_response": {}, + "unique_id": unique_id, + } + + +def _run_results(*statuses: str) -> dict: + return { + "metadata": { + "dbt_schema_version": V6_SCHEMA, + "dbt_version": "2.0.0", + "invocation_id": "test-invocation-123", + }, + "elapsed_time": 1.5, + "args": {}, + "results": [_result(s, f"model.proj.m{i}") for i, s in enumerate(statuses)], + } + + +class TestRunResultStatus: + """The run `Status` enum must accept new/unknown dbt statuses without failing.""" + + def test_reused_status_parses(self): + """dbt 2.0 emits `reused` for unchanged models; it must not fail validation.""" + result = Result(**_result("reused", "model.proj.a")) + assert result.status.value == "reused" + assert result.status is Status.reused + + def test_known_statuses_preserve_value(self): + """Known run statuses still resolve with the correct `.value`.""" + for status in ("success", "error", "skipped", "partial success"): + assert Result(**_result(status, "model.proj.a")).status.value == status + + def test_unknown_future_status_parses(self): + """Forward-compat: a status dbt has not shipped yet still parses via `_missing_`.""" + result = Result(**_result("some_future_status", "model.proj.a")) + assert result.status.value == "some_future_status" + + def test_test_and_freshness_statuses_keep_their_value(self): + """Test/freshness statuses must keep their `.value` (resolved via Status1/Status2).""" + for status in ("pass", "fail", "warn", "runtime error"): + assert Result(**_result(status, "test.proj.t")).status.value == status + + +class TestParseRunResultsEntryPoint: + """The public `parse_run_results` must parse a full file containing `reused`.""" + + def test_file_with_reused_result_parses_fully(self): + run_results = parse_run_results(_run_results("success", "reused", "skipped", "pass", "error", "no-op")) + # Previously this whole file was dropped because of the single `reused` row. + assert len(run_results.results) == 6 + assert {r.status.value for r in run_results.results} == { + "success", + "reused", + "skipped", + "pass", + "error", + "no-op", + }