This is an automated email from the ASF dual-hosted git repository.
ephraimbuddy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 829e0c70d43 Tidy DagVersion/DagCode metadata helpers (#68627)
829e0c70d43 is described below
commit 829e0c70d43de9fb41545b7d407e0a044a1f9245
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Tue Aug 25 17:57:40 2026 +0100
Tidy DagVersion/DagCode metadata helpers (#68627)
* Tidy DagVersion/DagCode metadata helpers
- get_version: use 'if version_number is not None' so version_number=0
filters
(and 404s) instead of falling through to the latest version.
- bundle_url: drop the dead hasattr(self.bundle, 'signed_url_template')
guard
(always true on the ORM model). Behavior is unchanged: a loaded bundle
uses
render_url(), and the deprecated manager lookup is used only when there
is no
bundle row -- not on every call for bundles without a URL template.
- DagCode.update_source_code: refresh fileloc alongside the source, and
drop the
redundant session.merge() on the already-session-attached row.
* fixup! Tidy DagVersion/DagCode metadata helpers
* fixup! Tidy DagVersion/DagCode metadata helpers
* Cover the deprecated bundle_url fallback in tests
Dropping the always-true signed_url_template guard left the manager
lookup, and the ValueError it can raise, with nothing exercising it, so a
regression on that path would pass CI unnoticed.
---
airflow-core/src/airflow/models/dag_version.py | 18 ++++----
airflow-core/src/airflow/models/dagcode.py | 4 +-
.../core_api/routes/public/test_dag_sources.py | 12 ++++++
.../core_api/routes/public/test_dag_versions.py | 19 ++++-----
airflow-core/tests/unit/models/test_dag_version.py | 40 ++++++++++++++++++
airflow-core/tests/unit/models/test_dagcode.py | 48 ++++++++++++++++++++++
6 files changed, 121 insertions(+), 20 deletions(-)
diff --git a/airflow-core/src/airflow/models/dag_version.py
b/airflow-core/src/airflow/models/dag_version.py
index 8aa7efcab01..602364e71b6 100644
--- a/airflow-core/src/airflow/models/dag_version.py
+++ b/airflow-core/src/airflow/models/dag_version.py
@@ -90,13 +90,14 @@ class DagVersion(Base):
@property
def bundle_url(self) -> str | None:
- """Render the bundle URL using the joined bundle metadata if
available."""
- # Prefer using the joined bundle relationship when present to avoid
extra queries
- if getattr(self, "bundle", None) is not None and hasattr(self.bundle,
"signed_url_template"):
- return self.bundle.render_url(self.bundle_version)
+ """Render the bundle URL from the bundle metadata row, when there is
one."""
+ # When a bundle row exists, use it (render_url returns None if it has
no URL template).
+ # Only when there is no bundle row do we fall back to the deprecated
manager lookup -- doing
+ # so for an empty template would hit the deprecated path (and its
warning) on every call.
+ bundle = getattr(self, "bundle", None)
+ if bundle is not None:
+ return bundle.render_url(self.bundle_version)
- # fallback to the deprecated option if the bundle model does not have
a signed_url_template
- # attribute
if self.bundle_name is None:
return None
try:
@@ -228,12 +229,13 @@ class DagVersion(Base):
Get the version of the DAG.
:param dag_id: The DAG ID.
- :param version_number: The version number.
+ :param version_number: The version number to look up. When ``None``,
the latest
+ version is returned; any other value -- ``0`` included -- is used
as a filter.
:param session: The database session.
:return: The version of the DAG or None if not found.
"""
version_select_obj = select(cls).where(cls.dag_id == dag_id)
- if version_number:
+ if version_number is not None:
version_select_obj = version_select_obj.where(cls.version_number
== version_number)
return
session.scalar(version_select_obj.order_by(cls.version_number.desc()).limit(1))
diff --git a/airflow-core/src/airflow/models/dagcode.py
b/airflow-core/src/airflow/models/dagcode.py
index c693f0384ce..b0306b15ff1 100644
--- a/airflow-core/src/airflow/models/dagcode.py
+++ b/airflow-core/src/airflow/models/dagcode.py
@@ -191,4 +191,6 @@ class DagCode(Base):
if new_source_code_hash != latest_dagcode.source_code_hash:
latest_dagcode.source_code = new_source_code
latest_dagcode.source_code_hash = new_source_code_hash
- session.merge(latest_dagcode)
+ # Keep fileloc aligned even when the contents are unchanged (e.g. the
file was moved/renamed).
+ if fileloc != latest_dagcode.fileloc:
+ latest_dagcode.fileloc = fileloc
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_sources.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_sources.py
index 7bcadfdb03d..93083102fed 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_sources.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_sources.py
@@ -182,6 +182,18 @@ class TestGetDAGSource:
response = test_client.get(url, headers={"Accept": "application/json"})
assert response.status_code == 404
+ def test_should_respond_404_for_version_number_zero(self, test_client,
test_dag):
+ """version_number=0 is a real filter that matches nothing, not a
fallback to the latest version."""
+ response = test_client.get(
+ f"{API_PREFIX}/{TEST_DAG_ID}",
+ params={"version_number": 0},
+ headers={"Accept": "application/json"},
+ )
+ assert response.status_code == 404
+ assert response.json() == {
+ "detail": f"The source code of the Dag {TEST_DAG_ID},
version_number 0 was not found"
+ }
+
@pytest.fixture
def colocated_unreadable_dag(self, session, test_dag):
"""Insert a second ``DagModel`` sharing the requested Dag's source
file."""
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_versions.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_versions.py
index 73051be51df..b182770caa8 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_versions.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_versions.py
@@ -177,20 +177,17 @@ class TestGetDagVersion(TestDagVersionEndpoint):
assert response.json() == expected_response
@pytest.mark.usefixtures("make_dag_with_multiple_versions")
-
@mock.patch("airflow.dag_processing.bundles.manager.DagBundlesManager.view_url")
- @mock.patch("airflow.models.dag_version.hasattr")
- def test_get_dag_version_with_unconfigured_bundle(
- self, mock_hasattr, mock_view_url, test_client, dag_maker, session
+ @mock.patch("airflow.models.dag_version.DagBundlesManager.view_url",
autospec=True)
+ @mock.patch("airflow.models.dagbundle.DagBundleModel.render_url",
autospec=True, return_value=None)
+ def test_get_dag_version_with_bundle_without_url_template(
+ self, mock_render_url, mock_view_url, test_client
):
- """Test that when a bundle is no longer configured, the bundle_url
returns an error message."""
- mock_hasattr.return_value = False
- mock_view_url.side_effect = ValueError("Bundle not configured")
-
+ """A bundle row with no URL template yields an empty bundle_url
without the deprecated fallback."""
response =
test_client.get("/dags/dag_with_multiple_versions/dagVersions/1")
assert response.status_code == 200
-
- response_data = response.json()
- assert not response_data["bundle_url"]
+ assert not response.json()["bundle_url"]
+ mock_render_url.assert_called_once()
+ mock_view_url.assert_not_called()
def test_get_dag_version_404(self, test_client):
response =
test_client.get("/dags/dag_with_multiple_versions/dagVersions/99")
diff --git a/airflow-core/tests/unit/models/test_dag_version.py
b/airflow-core/tests/unit/models/test_dag_version.py
index af78736826e..670a82b7a52 100644
--- a/airflow-core/tests/unit/models/test_dag_version.py
+++ b/airflow-core/tests/unit/models/test_dag_version.py
@@ -52,6 +52,16 @@ class TestDagVersion:
assert latest_version.version_number == 1
assert latest_version.dag_id == dag.dag_id
+ @pytest.mark.need_serialized_dag
+ def test_get_version_treats_zero_as_a_real_filter(self, dag_maker,
session):
+ """version_number=0 must filter (and find nothing), not fall through
to 'latest'."""
+ with dag_maker("zero_guard_dag"):
+ EmptyOperator(task_id="task1")
+
+ assert DagVersion.get_version("zero_guard_dag", 0, session=session) is
None
+ # version_number=None still returns the latest version.
+ assert DagVersion.get_version("zero_guard_dag",
session=session).version_number == 1
+
def test_writing_dag_version_with_changes(self, dag_maker, session):
"""This also tested the get_latest_version method"""
with dag_maker("test1") as dag:
@@ -182,6 +192,36 @@ class TestDagVersion:
assert retrieved.version_data is None
assert retrieved.bundle_version == "abc123"
+ @pytest.mark.parametrize(
+ ("view_url_kwargs", "expected"),
+ [
+ pytest.param(
+ {"return_value": "https://example.com/tree/abc"},
+ "https://example.com/tree/abc",
+ id="bundle-still-configured",
+ ),
+ pytest.param(
+ {"side_effect": ValueError("Bundle not configured")},
+ None,
+ id="bundle-no-longer-configured",
+ ),
+ ],
+ )
+ @mock.patch("airflow.models.dag_version.DagBundlesManager", autospec=True)
+ def test_bundle_url_falls_back_to_manager_without_a_bundle_row(
+ self, mock_manager, view_url_kwargs, expected
+ ):
+ """Without a dag_bundle row the deprecated manager lookup is the only
path left."""
+ mock_manager.return_value.view_url.configure_mock(**view_url_kwargs)
+ # Never persisted, so ``bundle`` resolves to None -- the same state a
Dag version
+ # whose bundle row is missing ends up in.
+ dag_version = DagVersion(
+ dag_id="dag_without_bundle_row", bundle_name="removed-bundle",
bundle_version="abc"
+ )
+
+ assert dag_version.bundle_url == expected
+
mock_manager.return_value.view_url.assert_called_once_with("removed-bundle",
"abc")
+
class TestResolveVersionData:
"""Unit tests for the _resolve_version_data pin-guard helper."""
diff --git a/airflow-core/tests/unit/models/test_dagcode.py
b/airflow-core/tests/unit/models/test_dagcode.py
index 5fdade754ee..1a0e16d95e5 100644
--- a/airflow-core/tests/unit/models/test_dagcode.py
+++ b/airflow-core/tests/unit/models/test_dagcode.py
@@ -214,3 +214,51 @@ class TestDagCode:
DagCode.update_source_code(dag.dag_id, dag.fileloc)
dag_code3 = DagCode.get_latest_dagcode(dag.dag_id)
assert dag_code3.source_code_hash != 2
+
+ def test_update_source_code_refreshes_fileloc(self, dag_maker, session):
+ """When the source changes, update_source_code also refreshes a stale
fileloc."""
+ with dag_maker("dag_fileloc") as dag:
+
+ @task_decorator
+ def mytask():
+ print("hi")
+
+ mytask()
+ sync_dag_to_db(dag)
+
+ dag_code = DagCode.get_latest_dagcode(dag.dag_id)
+ # Simulate a stale fileloc and a changed source so the update path is
taken.
+ dag_code.fileloc = "/old/stale/path.py"
+ dag_code.source_code_hash = "stalehash"
+ session.add(dag_code)
+ session.commit()
+
+ DagCode.update_source_code(dag.dag_id, dag.fileloc)
+
+ refreshed = DagCode.get_latest_dagcode(dag.dag_id)
+ assert refreshed.fileloc == dag.fileloc
+ assert refreshed.source_code_hash != "stalehash"
+
+ def test_update_source_code_refreshes_fileloc_when_source_unchanged(self,
dag_maker, session):
+ """A moved/renamed file with identical contents still refreshes a
stale fileloc."""
+ with dag_maker("dag_fileloc_moved") as dag:
+
+ @task_decorator
+ def mytask():
+ print("hi")
+
+ mytask()
+ sync_dag_to_db(dag)
+
+ dag_code = DagCode.get_latest_dagcode(dag.dag_id)
+ # Stale fileloc but the stored source hash still matches the file
contents.
+ dag_code.fileloc = "/old/stale/path.py"
+ session.add(dag_code)
+ session.commit()
+ original_hash = dag_code.source_code_hash
+
+ DagCode.update_source_code(dag.dag_id, dag.fileloc)
+
+ refreshed = DagCode.get_latest_dagcode(dag.dag_id)
+ assert refreshed.fileloc == dag.fileloc
+ assert refreshed.source_code_hash == original_hash