diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
index ea73f1fca135e..6bd9f4b5ebb79 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
@@ -84,7 +84,7 @@ class DAGResponse(BaseModel):
bundle_name: str | None
bundle_version: str | None
relative_fileloc: str | None
- fileloc: str
+ fileloc: str | None
description: str | None
timetable_summary: str | None
timetable_description: str | None
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 1777d56e50ed0..4a9136efda4f4 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -1871,7 +1871,9 @@ components:
- type: 'null'
title: Relative Fileloc
fileloc:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Fileloc
description:
anyOf:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 4ddd9d82042c4..ce209ee6f01ad 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -10199,7 +10199,9 @@ components:
- type: 'null'
title: Relative Fileloc
fileloc:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Fileloc
description:
anyOf:
@@ -10487,7 +10489,9 @@ components:
- type: 'null'
title: Relative Fileloc
fileloc:
- type: string
+ anyOf:
+ - type: string
+ - type: 'null'
title: Fileloc
description:
anyOf:
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 63c051cd1b848..4f9e9e169765b 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -1850,7 +1850,14 @@ export const $DAGDetailsResponse = {
title: 'Relative Fileloc'
},
fileloc: {
- type: 'string',
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Fileloc'
},
description: {
@@ -2291,7 +2298,14 @@ export const $DAGResponse = {
title: 'Relative Fileloc'
},
fileloc: {
- type: 'string',
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Fileloc'
},
description: {
@@ -7729,7 +7743,14 @@ export const $DAGWithLatestDagRunsResponse = {
title: 'Relative Fileloc'
},
fileloc: {
- type: 'string',
+ anyOf: [
+ {
+ type: 'string'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Fileloc'
},
description: {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 1ca6bf0203433..73454a348c596 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -536,7 +536,7 @@ export type DAGDetailsResponse = {
bundle_name: string | null;
bundle_version: string | null;
relative_fileloc: string | null;
- fileloc: string;
+ fileloc: string | null;
description: string | null;
timetable_summary: string | null;
timetable_description: string | null;
@@ -615,7 +615,7 @@ export type DAGResponse = {
bundle_name: string | null;
bundle_version: string | null;
relative_fileloc: string | null;
- fileloc: string;
+ fileloc: string | null;
description: string | null;
timetable_summary: string | null;
timetable_description: string | null;
@@ -1897,7 +1897,7 @@ export type DAGWithLatestDagRunsResponse = {
bundle_name: string | null;
bundle_version: string | null;
relative_fileloc: string | null;
- fileloc: string;
+ fileloc: string | null;
description: string | null;
timetable_summary: string | null;
timetable_description: string | null;
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Code/FileLocation.test.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Code/FileLocation.test.tsx
new file mode 100644
index 0000000000000..10330576f9832
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Code/FileLocation.test.tsx
@@ -0,0 +1,47 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { FileLocation } from "./FileLocation";
+
+describe("FileLocation", () => {
+ it("falls back to relativeFileloc when fileloc is null", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("example_dag.py")).toBeInTheDocument();
+ });
+
+ it("renders nothing when both fileloc and relativeFileloc are null", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Code/FileLocation.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Code/FileLocation.tsx
index 34c14d0c6b434..dabfb4b81045a 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Code/FileLocation.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Code/FileLocation.tsx
@@ -21,15 +21,21 @@ import { Box, Code, HStack } from "@chakra-ui/react";
import { ClipboardIconButton, ClipboardRoot, Tooltip } from "src/components/ui";
type FileLocationProps = {
- readonly fileloc: string;
+ readonly fileloc: string | null;
readonly relativeFileloc: string | null;
};
export const FileLocation = ({ fileloc, relativeFileloc }: FileLocationProps) => {
+ // A Dag with run history but no parsed source file has no fileloc to show.
+ if ((fileloc === null || fileloc === "") && (relativeFileloc === null || relativeFileloc === "")) {
+ return undefined;
+ }
+
+ const fullPath = fileloc ?? relativeFileloc ?? "";
const displayFilename =
relativeFileloc !== null && relativeFileloc !== ""
? relativeFileloc
- : (fileloc.split("/").at(-1) ?? fileloc);
+ : (fileloc?.split("/").at(-1) ?? fileloc ?? "");
return (
py={1}
>
-
+
{displayFilename}
-
+
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
index 55515b76931eb..5389cf94bd7c8 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
@@ -1292,6 +1292,26 @@ def test_get_dag_should_response_403(self, unauthorized_test_client):
assert response.status_code == 403
+class TestDagWithoutFileloc(TestDagEndpoint):
+ def _make_dag_without_fileloc(self, dag_maker, session, dag_id="test_dag_no_fileloc"):
+ with dag_maker(dag_id=dag_id, schedule=None):
+ EmptyOperator(task_id="task1")
+ dag_maker.create_dagrun(state=DagRunState.SUCCESS)
+ dag_maker.sync_dagbag_to_db()
+ dag_model = session.get(DagModel, dag_id)
+ dag_model.fileloc = None
+ dag_model.relative_fileloc = None
+ session.commit()
+ return dag_id
+
+ @pytest.mark.parametrize("path_suffix", ["", "/details"])
+ def test_detail_endpoints_serve_dag_with_null_fileloc(self, session, test_client, dag_maker, path_suffix):
+ dag_id = self._make_dag_without_fileloc(dag_maker, session)
+ response = test_client.get(f"/dags/{dag_id}{path_suffix}")
+ assert response.status_code == 200
+ assert response.json()["fileloc"] is None
+
+
class TestDeleteDAG(TestDagEndpoint):
"""Unit tests for Delete DAG."""
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 6983b39dc07bc..fe7d94cfd3bd0 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -1350,7 +1350,7 @@ class DAGDetailsResponse(BaseModel):
bundle_name: Annotated[str | None, Field(title="Bundle Name")] = None
bundle_version: Annotated[str | None, Field(title="Bundle Version")] = None
relative_fileloc: Annotated[str | None, Field(title="Relative Fileloc")] = None
- fileloc: Annotated[str, Field(title="Fileloc")]
+ fileloc: Annotated[str | None, Field(title="Fileloc")] = None
description: Annotated[str | None, Field(title="Description")] = None
timetable_summary: Annotated[str | None, Field(title="Timetable Summary")] = None
timetable_description: Annotated[str | None, Field(title="Timetable Description")] = None
@@ -1415,7 +1415,7 @@ class DAGResponse(BaseModel):
bundle_name: Annotated[str | None, Field(title="Bundle Name")] = None
bundle_version: Annotated[str | None, Field(title="Bundle Version")] = None
relative_fileloc: Annotated[str | None, Field(title="Relative Fileloc")] = None
- fileloc: Annotated[str, Field(title="Fileloc")]
+ fileloc: Annotated[str | None, Field(title="Fileloc")] = None
description: Annotated[str | None, Field(title="Description")] = None
timetable_summary: Annotated[str | None, Field(title="Timetable Summary")] = None
timetable_description: Annotated[str | None, Field(title="Timetable Description")] = None
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 76262b2c38e68..774a832cdd2f4 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -1255,6 +1255,18 @@ def handle_request(request: httpx.Request) -> httpx.Response:
response = client.dags.get("dag_id")
assert response == self.dag_response
+ def test_get_accepts_null_fileloc(self):
+ """Servers with #69110 return ``fileloc: null`` for a Dag whose file location is unknown."""
+ payload = json.loads(self.dag_response.model_dump_json())
+ payload["fileloc"] = None
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json=payload)
+
+ client = make_api_client(transport=httpx.MockTransport(handle_request))
+ response = client.dags.get("dag_id")
+ assert response.fileloc is None
+
def test_get_details(self):
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v2/dags/dag_id/details"