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

pierrejeambrun 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 113528cbb20 AIP-84 Add plugin error endpoint (#49436)
113528cbb20 is described below

commit 113528cbb206bbf44d04282861ed96a789bcbfbd
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Apr 18 17:32:49 2025 +0200

    AIP-84 Add plugin error endpoint (#49436)
---
 .../api_fastapi/core_api/datamodels/plugins.py     | 14 ++++++
 .../core_api/openapi/v1-rest-api-generated.yaml    | 57 ++++++++++++++++++++++
 .../api_fastapi/core_api/routes/public/plugins.py  | 27 ++++++++--
 .../src/airflow/ui/openapi-gen/queries/common.ts   | 10 ++++
 .../ui/openapi-gen/queries/ensureQueryData.ts      | 10 ++++
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts | 10 ++++
 .../src/airflow/ui/openapi-gen/queries/queries.ts  | 18 +++++++
 .../src/airflow/ui/openapi-gen/queries/suspense.ts | 18 +++++++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 37 ++++++++++++++
 .../ui/openapi-gen/requests/services.gen.ts        | 17 +++++++
 .../airflow/ui/openapi-gen/requests/types.gen.ts   | 36 ++++++++++++++
 .../core_api/routes/public/test_plugins.py         | 32 ++++++++++++
 12 files changed, 283 insertions(+), 3 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
index 1aab230ac3d..ad62e7b840a 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
@@ -98,3 +98,17 @@ class PluginCollectionResponse(BaseModel):
 
     plugins: list[PluginResponse]
     total_entries: int
+
+
+class PluginImportErrorResponse(BaseModel):
+    """Plugin Import Error serializer for responses."""
+
+    source: str
+    error: str
+
+
+class PluginImportErrorCollectionResponse(BaseModel):
+    """Plugin Import Error Collection serializer."""
+
+    import_errors: list[PluginImportErrorResponse]
+    total_entries: int
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
index 8e54659005b..c590ea0080b 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
@@ -3673,6 +3673,33 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HTTPValidationError'
+  /api/v2/plugins/importErrors:
+    get:
+      tags:
+      - Plugin
+      summary: Import Errors
+      operationId: import_errors
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: 
'#/components/schemas/PluginImportErrorCollectionResponse'
+        '401':
+          description: Unauthorized
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+      security:
+      - OAuth2PasswordBearer: []
   /api/v2/pools/{pool_name}:
     delete:
       tags:
@@ -9043,6 +9070,36 @@ components:
       - total_entries
       title: PluginCollectionResponse
       description: Plugin Collection serializer.
+    PluginImportErrorCollectionResponse:
+      properties:
+        import_errors:
+          items:
+            $ref: '#/components/schemas/PluginImportErrorResponse'
+          type: array
+          title: Import Errors
+        total_entries:
+          type: integer
+          title: Total Entries
+      type: object
+      required:
+      - import_errors
+      - total_entries
+      title: PluginImportErrorCollectionResponse
+      description: Plugin Import Error Collection serializer.
+    PluginImportErrorResponse:
+      properties:
+        source:
+          type: string
+          title: Source
+        error:
+          type: string
+          title: Error
+      type: object
+      required:
+      - source
+      - error
+      title: PluginImportErrorResponse
+      description: Plugin Import Error serializer for responses.
     PluginResponse:
       properties:
         name:
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/plugins.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/plugins.py
index 04ca85be433..fe700178f6b 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/plugins.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/plugins.py
@@ -21,12 +21,16 @@ from typing import cast
 
 from fastapi import Depends
 
+from airflow import plugins_manager
 from airflow.api_fastapi.auth.managers.models.resource_details import 
AccessView
 from airflow.api_fastapi.common.parameters import QueryLimit, QueryOffset
 from airflow.api_fastapi.common.router import AirflowRouter
-from airflow.api_fastapi.core_api.datamodels.plugins import 
PluginCollectionResponse, PluginResponse
+from airflow.api_fastapi.core_api.datamodels.plugins import (
+    PluginCollectionResponse,
+    PluginImportErrorCollectionResponse,
+    PluginResponse,
+)
 from airflow.api_fastapi.core_api.security import requires_access_view
-from airflow.plugins_manager import get_plugin_info
 
 plugins_router = AirflowRouter(tags=["Plugin"], prefix="/plugins")
 
@@ -39,8 +43,25 @@ def get_plugins(
     limit: QueryLimit,
     offset: QueryOffset,
 ) -> PluginCollectionResponse:
-    plugins_info = sorted(get_plugin_info(), key=lambda x: x["name"])
+    plugins_info = sorted(plugins_manager.get_plugin_info(), key=lambda x: 
x["name"])
     return PluginCollectionResponse(
         plugins=cast("list[PluginResponse]", plugins_info[offset.value :][: 
limit.value]),
         total_entries=len(plugins_info),
     )
+
+
+@plugins_router.get(
+    "/importErrors",
+    dependencies=[Depends(requires_access_view(AccessView.PLUGINS))],
+)
+def import_errors() -> PluginImportErrorCollectionResponse:
+    plugins_manager.ensure_plugins_loaded()  # make sure import_errors are 
loaded
+
+    return PluginImportErrorCollectionResponse.model_validate(
+        {
+            "import_errors": [
+                {"source": source, "error": error} for source, error in 
plugins_manager.import_errors.items()
+            ],
+            "total_entries": len(plugins_manager.import_errors),
+        }
+    )
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index a3fcabb4c27..ec6efb8c19d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1334,6 +1334,16 @@ export const UsePluginServiceGetPluginsKeyFn = (
   } = {},
   queryKey?: Array<unknown>,
 ) => [usePluginServiceGetPluginsKey, ...(queryKey ?? [{ limit, offset }])];
+export type PluginServiceImportErrorsDefaultResponse = 
Awaited<ReturnType<typeof PluginService.importErrors>>;
+export type PluginServiceImportErrorsQueryResult<
+  TData = PluginServiceImportErrorsDefaultResponse,
+  TError = unknown,
+> = UseQueryResult<TData, TError>;
+export const usePluginServiceImportErrorsKey = "PluginServiceImportErrors";
+export const UsePluginServiceImportErrorsKeyFn = (queryKey?: Array<unknown>) 
=> [
+  usePluginServiceImportErrorsKey,
+  ...(queryKey ?? []),
+];
 export type PoolServiceGetPoolDefaultResponse = Awaited<ReturnType<typeof 
PoolService.getPool>>;
 export type PoolServiceGetPoolQueryResult<
   TData = PoolServiceGetPoolDefaultResponse,
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index cd06c2bff30..29323892621 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -1845,6 +1845,16 @@ export const ensureUsePluginServiceGetPluginsData = (
     queryKey: Common.UsePluginServiceGetPluginsKeyFn({ limit, offset }),
     queryFn: () => PluginService.getPlugins({ limit, offset }),
   });
+/**
+ * Import Errors
+ * @returns PluginImportErrorCollectionResponse Successful Response
+ * @throws ApiError
+ */
+export const ensureUsePluginServiceImportErrorsData = (queryClient: 
QueryClient) =>
+  queryClient.ensureQueryData({
+    queryKey: Common.UsePluginServiceImportErrorsKeyFn(),
+    queryFn: () => PluginService.importErrors(),
+  });
 /**
  * Get Pool
  * Get a pool.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index 14649a703b1..d9328eab70b 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -1845,6 +1845,16 @@ export const prefetchUsePluginServiceGetPlugins = (
     queryKey: Common.UsePluginServiceGetPluginsKeyFn({ limit, offset }),
     queryFn: () => PluginService.getPlugins({ limit, offset }),
   });
+/**
+ * Import Errors
+ * @returns PluginImportErrorCollectionResponse Successful Response
+ * @throws ApiError
+ */
+export const prefetchUsePluginServiceImportErrors = (queryClient: QueryClient) 
=>
+  queryClient.prefetchQuery({
+    queryKey: Common.UsePluginServiceImportErrorsKeyFn(),
+    queryFn: () => PluginService.importErrors(),
+  });
 /**
  * Get Pool
  * Get a pool.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index ae20d5e3d76..c05706beec8 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2193,6 +2193,24 @@ export const usePluginServiceGetPlugins = <
     queryFn: () => PluginService.getPlugins({ limit, offset }) as TData,
     ...options,
   });
+/**
+ * Import Errors
+ * @returns PluginImportErrorCollectionResponse Successful Response
+ * @throws ApiError
+ */
+export const usePluginServiceImportErrors = <
+  TData = Common.PluginServiceImportErrorsDefaultResponse,
+  TError = unknown,
+  TQueryKey extends Array<unknown> = unknown[],
+>(
+  queryKey?: TQueryKey,
+  options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">,
+) =>
+  useQuery<TData, TError>({
+    queryKey: Common.UsePluginServiceImportErrorsKeyFn(queryKey),
+    queryFn: () => PluginService.importErrors() as TData,
+    ...options,
+  });
 /**
  * Get Pool
  * Get a pool.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 0bb328cbb24..3180bd87b93 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -2170,6 +2170,24 @@ export const usePluginServiceGetPluginsSuspense = <
     queryFn: () => PluginService.getPlugins({ limit, offset }) as TData,
     ...options,
   });
+/**
+ * Import Errors
+ * @returns PluginImportErrorCollectionResponse Successful Response
+ * @throws ApiError
+ */
+export const usePluginServiceImportErrorsSuspense = <
+  TData = Common.PluginServiceImportErrorsDefaultResponse,
+  TError = unknown,
+  TQueryKey extends Array<unknown> = unknown[],
+>(
+  queryKey?: TQueryKey,
+  options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">,
+) =>
+  useSuspenseQuery<TData, TError>({
+    queryKey: Common.UsePluginServiceImportErrorsKeyFn(queryKey),
+    queryFn: () => PluginService.importErrors() as TData,
+    ...options,
+  });
 /**
  * Get Pool
  * Get a pool.
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 7c9f8b2bf5f..142cc6db7b5 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
@@ -3450,6 +3450,43 @@ export const $PluginCollectionResponse = {
   description: "Plugin Collection serializer.",
 } as const;
 
+export const $PluginImportErrorCollectionResponse = {
+  properties: {
+    import_errors: {
+      items: {
+        $ref: "#/components/schemas/PluginImportErrorResponse",
+      },
+      type: "array",
+      title: "Import Errors",
+    },
+    total_entries: {
+      type: "integer",
+      title: "Total Entries",
+    },
+  },
+  type: "object",
+  required: ["import_errors", "total_entries"],
+  title: "PluginImportErrorCollectionResponse",
+  description: "Plugin Import Error Collection serializer.",
+} as const;
+
+export const $PluginImportErrorResponse = {
+  properties: {
+    source: {
+      type: "string",
+      title: "Source",
+    },
+    error: {
+      type: "string",
+      title: "Error",
+    },
+  },
+  type: "object",
+  required: ["source", "error"],
+  title: "PluginImportErrorResponse",
+  description: "Plugin Import Error serializer for responses.",
+} as const;
+
 export const $PluginResponse = {
   properties: {
     name: {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index 385f8256e7f..b9c97a08128 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -154,6 +154,7 @@ import type {
   GetJobsResponse,
   GetPluginsData,
   GetPluginsResponse,
+  ImportErrorsResponse,
   DeletePoolData,
   DeletePoolResponse,
   GetPoolData,
@@ -2674,6 +2675,22 @@ export class PluginService {
       },
     });
   }
+
+  /**
+   * Import Errors
+   * @returns PluginImportErrorCollectionResponse Successful Response
+   * @throws ApiError
+   */
+  public static importErrors(): CancelablePromise<ImportErrorsResponse> {
+    return __request(OpenAPI, {
+      method: "GET",
+      url: "/api/v2/plugins/importErrors",
+      errors: {
+        401: "Unauthorized",
+        403: "Forbidden",
+      },
+    });
+  }
 }
 
 export class PoolService {
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 18528b9da12..6f3d2f0147b 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
@@ -909,6 +909,22 @@ export type PluginCollectionResponse = {
   total_entries: number;
 };
 
+/**
+ * Plugin Import Error Collection serializer.
+ */
+export type PluginImportErrorCollectionResponse = {
+  import_errors: Array<PluginImportErrorResponse>;
+  total_entries: number;
+};
+
+/**
+ * Plugin Import Error serializer for responses.
+ */
+export type PluginImportErrorResponse = {
+  source: string;
+  error: string;
+};
+
 /**
  * Plugin serializer.
  */
@@ -2413,6 +2429,8 @@ export type GetPluginsData = {
 
 export type GetPluginsResponse = PluginCollectionResponse;
 
+export type ImportErrorsResponse = PluginImportErrorCollectionResponse;
+
 export type DeletePoolData = {
   poolName: string;
 };
@@ -4730,6 +4748,24 @@ export type $OpenApiTs = {
       };
     };
   };
+  "/api/v2/plugins/importErrors": {
+    get: {
+      res: {
+        /**
+         * Successful Response
+         */
+        200: PluginImportErrorCollectionResponse;
+        /**
+         * Unauthorized
+         */
+        401: HTTPExceptionResponse;
+        /**
+         * Forbidden
+         */
+        403: HTTPExceptionResponse;
+      };
+    };
+  };
   "/api/v2/pools/{pool_name}": {
     delete: {
       req: DeletePoolData;
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
index 392b7dcb07d..c16e1f114a9 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
@@ -16,6 +16,8 @@
 # under the License.
 from __future__ import annotations
 
+from unittest.mock import patch
+
 import pytest
 
 from tests_common.test_utils.markers import 
skip_if_force_lowest_dependencies_marker
@@ -73,3 +75,33 @@ class TestGetPlugins:
     def test_should_response_403(self, unauthorized_test_client):
         response = unauthorized_test_client.get("/plugins")
         assert response.status_code == 403
+
+
+@skip_if_force_lowest_dependencies_marker
+class TestGetPluginImportErrors:
+    @patch(
+        "airflow.plugins_manager.import_errors",
+        new={"plugins/test_plugin.py": "something went wrong"},
+    )
+    def test_should_respond_200(self, test_client, session):
+        response = test_client.get("/plugins/importErrors")
+        assert response.status_code == 200
+
+        body = response.json()
+        assert body == {
+            "import_errors": [
+                {
+                    "source": "plugins/test_plugin.py",
+                    "error": "something went wrong",
+                }
+            ],
+            "total_entries": 1,
+        }
+
+    def test_should_response_401(self, unauthenticated_test_client):
+        response = unauthenticated_test_client.get("/plugins/importErrors")
+        assert response.status_code == 401
+
+    def test_should_response_403(self, unauthorized_test_client):
+        response = unauthorized_test_client.get("/plugins/importErrors")
+        assert response.status_code == 403

Reply via email to