This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this 
push:
     new a40be82a7d8 [airflow-ctl/v0-1-test] Fix Dag detail page 500 for Dags 
without a fileloc #69035 (#69110) (#76074)
a40be82a7d8 is described below

commit a40be82a7d8a1f9273314e50038d44e5142229f6
Author: Shahar Epstein <[email protected]>
AuthorDate: Fri Oct 30 20:28:12 2026 +0200

    [airflow-ctl/v0-1-test] Fix Dag detail page 500 for Dags without a fileloc 
#69035 (#69110) (#76074)
    
    Only the fileloc datamodel change, its UI handling and tests are 
backported, so airflowctl 0.1.x can read Dags that Airflow 3.4 servers return 
with a null fileloc. The spec, _private_ui.yaml, UI openapi-gen and airflowctl 
datamodels were regenerated with this branch's generators. Adds an airflowctl 
test for a null fileloc.
    
    (cherry picked from commit 7790bb52994a69bdcfd3bcaf4cdbd846797eaf6c)
    
    Co-authored-by: Haseeb Malik 
<[email protected]>
---
 .../api_fastapi/core_api/datamodels/dags.py        |  2 +-
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  4 +-
 .../core_api/openapi/v2-rest-api-generated.yaml    |  8 +++-
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 27 +++++++++++--
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  6 +--
 .../ui/src/pages/Dag/Code/FileLocation.test.tsx    | 47 ++++++++++++++++++++++
 .../airflow/ui/src/pages/Dag/Code/FileLocation.tsx | 14 +++++--
 .../core_api/routes/public/test_dags.py            | 20 +++++++++
 .../src/airflowctl/api/datamodels/generated.py     |  4 +-
 .../tests/airflow_ctl/api/test_operations.py       | 12 ++++++
 10 files changed, 128 insertions(+), 16 deletions(-)

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 ea73f1fca13..6bd9f4b5ebb 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 3ae655b924e..598b5e94ffb 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 c58274f46c7..f0875f305bb 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 ee7206e2b0b..0c7ee2e5f1d 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: {
@@ -7736,7 +7750,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 16a3d59b86b..2feb9ab4566 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 00000000000..10330576f98
--- /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(
+      <Wrapper>
+        <FileLocation fileloc={null} relativeFileloc="example_dag.py" />
+      </Wrapper>,
+    );
+
+    expect(screen.getByText("example_dag.py")).toBeInTheDocument();
+  });
+
+  it("renders nothing when both fileloc and relativeFileloc are null", () => {
+    const { container } = render(
+      <Wrapper>
+        <FileLocation fileloc={null} relativeFileloc={null} />
+      </Wrapper>,
+    );
+
+    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 34c14d0c6b4..dabfb4b8104 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 (
     <Box
@@ -41,12 +47,12 @@ export const FileLocation = ({ fileloc, relativeFileloc }: 
FileLocationProps) =>
       py={1}
     >
       <HStack gap={2}>
-        <Tooltip closeDelay={100} content={fileloc} openDelay={100}>
+        <Tooltip closeDelay={100} content={fullPath} openDelay={100}>
           <Code fontSize="13px" wordBreak="break-word">
             {displayFilename}
           </Code>
         </Tooltip>
-        <ClipboardRoot value={fileloc}>
+        <ClipboardRoot value={fullPath}>
           <ClipboardIconButton size="2xs" />
         </ClipboardRoot>
       </HStack>
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 55515b76931..5389cf94bd7 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 @@ class TestGetDag(TestDagEndpoint):
         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 0c418d238ba..b8f1e473dad 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -1351,7 +1351,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
@@ -1416,7 +1416,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 56f4567718c..80c8173bf52 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 @@ class TestDagOperations:
         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"

Reply via email to