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"