Repository: astronomer/astronomer-cosmos
Pull request #1692: Disable CosmosPlugin for AF3
Author: login: pankajkoti
Labels: name: area:docs
description: Relating to documentation, changes, fixes, improvement
name: size:XL
description: This PR changes 500-999 lines, ignoring generated files.
name: lgtm
description: This PR has been approved by a maintainer
name: area:plugin
description: Related to the Cosmos AirflowPlugin.
Created: 2025-04-24T14:22:30Z | Merged: 2025-04-25T11:09:32Z

While I am not seeing any explicit errors with having the CosmosPlugin in an Airflow 3 env, I think it's still wise that we disable it for now for Airflow 3 until we work on #1587 


closes: #1685 


Diff:
diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py
index 1b68b0b88b..84663663fb 100644
--- a/cosmos/plugin/__init__.py
+++ b/cosmos/plugin/__init__.py
@@ -1,273 +1,11 @@
-import os.path as op
-from typing import Any, Dict, Optional, Tuple
-from urllib.parse import urlsplit
+from airflow import __version__ as airflow_version
+from packaging import version
 
-from airflow.configuration import conf
-from airflow.plugins_manager import AirflowPlugin
-from airflow.security import permissions
-from airflow.www.auth import has_access
-from airflow.www.views import AirflowBaseView
-from flask import abort, url_for
-from flask_appbuilder import AppBuilder, expose
+from cosmos.constants import _AIRFLOW3_MAJOR_VERSION
 
-from cosmos.listeners import dag_run_listener
-from cosmos.settings import dbt_docs_conn_id, dbt_docs_dir, dbt_docs_index_file_name, in_astro_cloud
-
-if in_astro_cloud:
-    MENU_ACCESS_PERMISSIONS = [
-        (permissions.ACTION_CAN_ACCESS_MENU, "Custom Menu"),
-        (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
-    ]
-else:
-    MENU_ACCESS_PERMISSIONS = [
-        (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
-    ]
-
-
-def bucket_and_key(path: str) -> Tuple[str, str]:
-    parsed_url = urlsplit(path)
-    bucket = parsed_url.netloc
-    key = parsed_url.path.lstrip("/")
-    return bucket, key
-
-
-def open_s3_file(path: str, conn_id: Optional[str]) -> str:
-    from airflow.providers.amazon.aws.hooks.s3 import S3Hook
-    from botocore.exceptions import ClientError
-
-    if conn_id is None:
-        conn_id = S3Hook.default_conn_name
-
-    hook = S3Hook(aws_conn_id=conn_id)
-    bucket, key = bucket_and_key(path)
-    try:
-        content = hook.read_key(key=key, bucket_name=bucket)
-    except ClientError as e:
-        if e.response.get("Error", {}).get("Code", "") == "NoSuchKey":
-            raise FileNotFoundError(f"{path} does not exist")
-        raise e
-    return content  # type: ignore[no-any-return]
-
-
-def open_gcs_file(path: str, conn_id: Optional[str]) -> str:
-    from airflow.providers.google.cloud.hooks.gcs import GCSHook
-    from google.cloud.exceptions import NotFound
-
-    if conn_id is None:
-        conn_id = GCSHook.default_conn_name
-
-    hook = GCSHook(gcp_conn_id=conn_id)
-    bucket, blob = bucket_and_key(path)
-    try:
-        content = hook.download(bucket_name=bucket, object_name=blob)
-    except NotFound:
-        raise FileNotFoundError(f"{path} does not exist")
-    return content.decode("utf-8")  # type: ignore[no-any-return]
-
-
-def open_azure_file(path: str, conn_id: Optional[str]) -> str:
-    from airflow.providers.microsoft.azure.hooks.wasb import WasbHook
-    from azure.core.exceptions import ResourceNotFoundError
-
-    if conn_id is None:
-        conn_id = WasbHook.default_conn_name
-
-    hook = WasbHook(wasb_conn_id=conn_id)
-
-    container, blob = bucket_and_key(path)
-    try:
-        content = hook.read_file(container_name=container, blob_name=blob)
-    except ResourceNotFoundError:
-        raise FileNotFoundError(f"{path} does not exist")
-    return content  # type: ignore[no-any-return]
-
-
-def open_http_file(path: str, conn_id: Optional[str]) -> str:
-    from airflow.providers.http.hooks.http import HttpHook
-    from requests.exceptions import HTTPError
-
-    if conn_id is None:
-        conn_id = ""
-
-    hook = HttpHook(method="GET", http_conn_id=conn_id)
-    try:
-        res = hook.run(endpoint=path)
-        hook.check_response(res)
-    except HTTPError as e:
-        if str(e).startswith("404"):
-            raise FileNotFoundError(f"{path} does not exist")
-        raise e
-    return res.text  # type: ignore[no-any-return]
-
-
-def open_file(path: str, conn_id: Optional[str] = None) -> str:
-    """
-    Retrieve a file from http, https, gs, s3, or wasb.
-
-    Raise a (base Python) FileNotFoundError if the file is not found.
-    """
-    if path.strip().startswith("s3://"):
-        return open_s3_file(path, conn_id=conn_id)
-    elif path.strip().startswith("gs://"):
-        return open_gcs_file(path, conn_id=conn_id)
-    elif path.strip().startswith("wasb://"):
-        return open_azure_file(path, conn_id=conn_id)
-    elif path.strip().startswith("http://") or path.strip().startswith("https://"):
-        return open_http_file(path, conn_id=conn_id)
-    else:
-        with open(path) as f:
-            content = f.read()
-        return content  # type: ignore[no-any-return]
-
-
-iframe_script = """
-<script>
-  function getMaxElement(side, elements_query) {
-    var elements = document.querySelectorAll(elements_query)
-    var elementsLength = elements.length,
-      elVal = 0,
-      maxVal = 0,
-      Side = capitalizeFirstLetter(side),
-      timer = Date.now()
-
-    for (var i = 0; i < elementsLength; i++) {
-      elVal =
-        elements[i].getBoundingClientRect()[side] +
-        getComputedStyleWrapper('margin' + Side, elements[i])
-      if (elVal > maxVal) {
-        maxVal = elVal
-      }
-    }
-
-    timer = Date.now() - timer
-
-    chkEventThottle(timer)
-
-    return maxVal
-  }
-  var throttledTimer = 16
-  function chkEventThottle(timer) {
-    if (timer > throttledTimer / 2) {
-      throttledTimer = 2 * timer
-    }
-  }
-  function capitalizeFirstLetter(string) {
-    return string.charAt(0).toUpperCase() + string.slice(1)
-  }
-  function getComputedStyleWrapper(prop, el) {
-    var retVal = 0
-    el = el || document.body // Not testable in phantonJS
-
-    retVal = document.defaultView.getComputedStyle(el, null)
-    retVal = null === retVal ? 0 : retVal[prop]
-
-    return parseInt(retVal)
-  }
-  window.iFrameResizer = {
-    heightCalculationMethod: function getHeight() {
-      return Math.max(
-        // Overview page
-        getMaxElement('bottom', 'div.panel.panel-default') + 50,
-        // Model page
-        getMaxElement('bottom', 'section.section') + 75,
-        // Search page
-        getMaxElement('bottom', 'div.result-body') + 125
-      )
-    }
-  }
-
-  // Prevent parent hash changes from sending a message back to the parent.
-  // This is necessary for making sure the browser back button works properly.
-  let hashChangeLock = true;
-
-  window.addEventListener('hashchange', function () {
-    if (!hashChangeLock) {
-      window.parent.postMessage(window.location.hash);
-    }
-    hashChangeLock = false;
-  });
-  window.addEventListener('message', function (event) {
-    let msgData = event.data;
-    if (typeof msgData === 'string' && msgData.startsWith('#!')) {
-      let updateUrl = new URL(window.location);
-      updateUrl.hash = msgData;
-      hashChangeLock = true;
-      history.replaceState(null, null, updateUrl);
-    }
-  });
-</script>
-"""
-
-
-class DbtDocsView(AirflowBaseView):  # type: ignore
-    default_view = "dbt_docs"
-    route_base = "/cosmos"
-    template_folder = op.join(op.dirname(__file__), "templates")
-    static_folder = op.join(op.dirname(__file__), "static")
-
-    def create_blueprint(
-        self, appbuilder: AppBuilder, endpoint: Optional[str] = None, static_folder: Optional[str] = None
-    ) -> None:
-        # Make sure the static folder is not overwritten, as we want to use it.
-        return super().create_blueprint(appbuilder, endpoint=endpoint, static_folder=self.static_folder)  # type: ignore[no-any-return]
-
-    @expose("/dbt_docs")  # type: ignore[misc]
-    @has_access(MENU_ACCESS_PERMISSIONS)  # type: ignore[misc]
-    def dbt_docs(self) -> str:
-        if dbt_docs_dir is None:
-            return self.render_template("dbt_docs_not_set_up.html")  # type: ignore[no-any-return,no-untyped-call]
-        return self.render_template("dbt_docs.html")  # type: ignore[no-any-return,no-untyped-call]
-
-    @expose("/dbt_docs_index.html")  # type: ignore[misc]
-    @has_access(MENU_ACCESS_PERMISSIONS)  # type: ignore[misc]
-    def dbt_docs_index(self) -> Tuple[str, int, Dict[str, Any]]:
-        if dbt_docs_dir is None:
-            abort(404)
-        try:
-            html = open_file(op.join(dbt_docs_dir, dbt_docs_index_file_name), conn_id=dbt_docs_conn_id)
-        except FileNotFoundError:
-            abort(404)
-        else:
-            # Hack the dbt docs to render properly in an iframe
-            iframe_resizer_url = url_for(".static", filename="iframeResizer.contentWindow.min.js")
-            html = html.replace("</head>", f'{iframe_script}<script src="{iframe_resizer_url}"></script></head>', 1)
-            return html, 200, {"Content-Security-Policy": "frame-ancestors 'self'"}
-
-    @expose("/catalog.json")  # type: ignore[misc]
-    @has_access(MENU_ACCESS_PERMISSIONS)  # type: ignore[misc]
-    def catalog(self) -> Tuple[str, int, Dict[str, Any]]:
-        if dbt_docs_dir is None:
-            abort(404)
-        try:
-            data = open_file(op.join(dbt_docs_dir, "catalog.json"), conn_id=dbt_docs_conn_id)
-        except FileNotFoundError:
-            abort(404)
-        else:
-            return data, 200, {"Content-Type": "application/json"}
-
-    @expose("/manifest.json")  # type: ignore[misc]
-    @has_access(MENU_ACCESS_PERMISSIONS)  # type: ignore[misc]
-    def manifest(self) -> Tuple[str, int, Dict[str, Any]]:
-        if dbt_docs_dir is None:
-            abort(404)
-        try:
-            data = open_file(op.join(dbt_docs_dir, "manifest.json"), conn_id=dbt_docs_conn_id)
-        except FileNotFoundError:
-            abort(404)
-        else:
-            return data, 200, {"Content-Type": "application/json"}
-
-
-dbt_docs_view = DbtDocsView()
-
-
-class CosmosPlugin(AirflowPlugin):
-    name = "cosmos"
-    item = {
-        "name": "dbt Docs",
-        "category": "Browse",
-        "view": dbt_docs_view,
-        "href": conf.get("webserver", "base_url") + "/cosmos/dbt_docs",
-    }
-    appbuilder_views = [item]
-    listeners = [dag_run_listener]
+# The plugin is only loaded if the Airflow version is less than 3.0. This is because the plugin is incompatible with
+# Airflow 3.0 and above. Once the compatibility issue is resolved as part of
+# https://github.com/astronomer/astronomer-cosmos/issues/1587, the import statement can be moved outside of the
+# conditional block.
+if version.parse(airflow_version).major < _AIRFLOW3_MAJOR_VERSION:
+    from .plugin_impl import CosmosPlugin as CosmosPlugin
diff --git a/cosmos/plugin/plugin_impl.py b/cosmos/plugin/plugin_impl.py
new file mode 100644
index 0000000000..1b68b0b88b
--- /dev/null
+++ b/cosmos/plugin/plugin_impl.py
@@ -0,0 +1,273 @@
+import os.path as op
+from typing import Any, Dict, Optional, Tuple
+from urllib.parse import urlsplit
+
+from airflow.configuration import conf
+from airflow.plugins_manager import AirflowPlugin
+from airflow.security import permissions
+from airflow.www.auth import has_access
+from airflow.www.views import AirflowBaseView
+from flask import abort, url_for
+from flask_appbuilder import AppBuilder, expose
+
+from cosmos.listeners import dag_run_listener
+from cosmos.settings import dbt_docs_conn_id, dbt_docs_dir, dbt_docs_index_file_name, in_astro_cloud
+
+if in_astro_cloud:
+    MENU_ACCESS_PERMISSIONS = [
+        (permissions.ACTION_CAN_ACCESS_MENU, "Custom Menu"),
+        (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
+    ]
+else:
+    MENU_ACCESS_PERMISSIONS = [
+        (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
+    ]
+
+
+def bucket_and_key(path: str) -> Tuple[str, str]:
+    parsed_url = urlsplit(path)
+    bucket = parsed_url.netloc
+    key = parsed_url.path.lstrip("/")
+    return bucket, key
+
+
+def open_s3_file(path: str, conn_id: Optional[str]) -> str:
+    from airflow.providers.amazon.aws.hooks.s3 import S3Hook
+    from botocore.exceptions import ClientError
+
+    if conn_id is None:
+        conn_id = S3Hook.default_conn_name
+
+    hook = S3Hook(aws_conn_id=conn_id)
+    bucket, key = bucket_and_key(path)
+    try:
+        content = hook.read_key(key=key, bucket_name=bucket)
+    except ClientError as e:
+        if e.response.get("Error", {}).get("Code", "") == "NoSuchKey":
+            raise FileNotFoundError(f"{path} does not exist")
+        raise e
+    return content  # type: ignore[no-any-return]
+
+
+def open_gcs_file(path: str, conn_id: Optional[str]) -> str:
+    from airflow.providers.google.cloud.hooks.gcs import GCSHook
+    from google.cloud.exceptions import NotFound
+
+    if conn_id is None:
+        conn_id = GCSHook.default_conn_name
+
+    hook = GCSHook(gcp_conn_id=conn_id)
+    bucket, blob = bucket_and_key(path)
+    try:
+        content = hook.download(bucket_name=bucket, object_name=blob)
+    except NotFound:
+        raise FileNotFoundError(f"{path} does not exist")
+    return content.decode("utf-8")  # type: ignore[no-any-return]
+
+
+def open_azure_file(path: str, conn_id: Optional[str]) -> str:
+    from airflow.providers.microsoft.azure.hooks.wasb import WasbHook
+    from azure.core.exceptions import ResourceNotFoundError
+
+    if conn_id is None:
+        conn_id = WasbHook.default_conn_name
+
+    hook = WasbHook(wasb_conn_id=conn_id)
+
+    container, blob = bucket_and_key(path)
+    try:
+        content = hook.read_file(container_name=container, blob_name=blob)
+    except ResourceNotFoundError:
+        raise FileNotFoundError(f"{path} does not exist")
+    return content  # type: ignore[no-any-return]
+
+
+def open_http_file(path: str, conn_id: Optional[str]) -> str:
+    from airflow.providers.http.hooks.http import HttpHook
+    from requests.exceptions import HTTPError
+
+    if conn_id is None:
+        conn_id = ""
+
+    hook = HttpHook(method="GET", http_conn_id=conn_id)
+    try:
+        res = hook.run(endpoint=path)
+        hook.check_response(res)
+    except HTTPError as e:
+        if str(e).startswith("404"):
+            raise FileNotFoundError(f"{path} does not exist")
+        raise e
+    return res.text  # type: ignore[no-any-return]
+
+
+def open_file(path: str, conn_id: Optional[str] = None) -> str:
+    """
+    Retrieve a file from http, https, gs, s3, or wasb.
+
+    Raise a (base Python) FileNotFoundError if the file is not found.
+    """
+    if path.strip().startswith("s3://"):
+        return open_s3_file(path, conn_id=conn_id)
+    elif path.strip().startswith("gs://"):
+        return open_gcs_file(path, conn_id=conn_id)
+    elif path.strip().startswith("wasb://"):
+        return open_azure_file(path, conn_id=conn_id)
+    elif path.strip().startswith("http://") or path.strip().startswith("https://"):
+        return open_http_file(path, conn_id=conn_id)
+    else:
+        with open(path) as f:
+            content = f.read()
+        return content  # type: ignore[no-any-return]
+
+
+iframe_script = """
+<script>
+  function getMaxElement(side, elements_query) {
+    var elements = document.querySelectorAll(elements_query)
+    var elementsLength = elements.length,
+      elVal = 0,
+      maxVal = 0,
+      Side = capitalizeFirstLetter(side),
+      timer = Date.now()
+
+    for (var i = 0; i < elementsLength; i++) {
+      elVal =
+        elements[i].getBoundingClientRect()[side] +
+        getComputedStyleWrapper('margin' + Side, elements[i])
+      if (elVal > maxVal) {
+        maxVal = elVal
+      }
+    }
+
+    timer = Date.now() - timer
+
+    chkEventThottle(timer)
+
+    return maxVal
+  }
+  var throttledTimer = 16
+  function chkEventThottle(timer) {
+    if (timer > throttledTimer / 2) {
+      throttledTimer = 2 * timer
+    }
+  }
+  function capitalizeFirstLetter(string) {
+    return string.charAt(0).toUpperCase() + string.slice(1)
+  }
+  function getComputedStyleWrapper(prop, el) {
+    var retVal = 0
+    el = el || document.body // Not testable in phantonJS
+
+    retVal = document.defaultView.getComputedStyle(el, null)
+    retVal = null === retVal ? 0 : retVal[prop]
+
+    return parseInt(retVal)
+  }
+  window.iFrameResizer = {
+    heightCalculationMethod: function getHeight() {
+      return Math.max(
+        // Overview page
+        getMaxElement('bottom', 'div.panel.panel-default') + 50,
+        // Model page
+        getMaxElement('bottom', 'section.section') + 75,
+        // Search page
+        getMaxElement('bottom', 'div.result-body') + 125
+      )
+    }
+  }
+
+  // Prevent parent hash changes from sending a message back to the parent.
+  // This is necessary for making sure the browser back button works properly.
+  let hashChangeLock = true;
+
+  window.addEventListener('hashchange', function () {
+    if (!hashChangeLock) {
+      window.parent.postMessage(window.location.hash);
+    }
+    hashChangeLock = false;
+  });
+  window.addEventListener('message', function (event) {
+    let msgData = event.data;
+    if (typeof msgData === 'string' && msgData.startsWith('#!')) {
+      let updateUrl = new URL(window.location);
+      updateUrl.hash = msgData;
+      hashChangeLock = true;
+      history.replaceState(null, null, updateUrl);
+    }
+  });
+</script>
+"""
+
+
+class DbtDocsView(AirflowBaseView):  # type: ignore
+    default_view = "dbt_docs"
+    route_base = "/cosmos"
+    template_folder = op.join(op.dirname(__file__), "templates")
+    static_folder = op.join(op.dirname(__file__), "static")
+
+    def create_blueprint(
+        self, appbuilder: AppBuilder, endpoint: Optional[str] = None, static_folder: Optional[str] = None
+    ) -> None:
+        # Make sure the static folder is not overwritten, as we want to use it.
+        return super().create_blueprint(appbuilder, endpoint=endpoint, static_folder=self.static_folder)  # type: ignore[no-any-return]
+
+    @expose("/dbt_docs")  # type: ignore[misc]
+    @has_access(MENU_ACCESS_PERMISSIONS)  # type: ignore[misc]
+    def dbt_docs(self) -> str:
+        if dbt_docs_dir is None:
+            return self.render_template("dbt_docs_not_set_up.html")  # type: ignore[no-any-return,no-untyped-call]
+        return self.render_template("dbt_docs.html")  # type: ignore[no-any-return,no-untyped-call]
+
+    @expose("/dbt_docs_index.html")  # type: ignore[misc]
+    @has_access(MENU_ACCESS_PERMISSIONS)  # type: ignore[misc]
+    def dbt_docs_index(self) -> Tuple[str, int, Dict[str, Any]]:
+        if dbt_docs_dir is None:
+            abort(404)
+        try:
+            html = open_file(op.join(dbt_docs_dir, dbt_docs_index_file_name), conn_id=dbt_docs_conn_id)
+        except FileNotFoundError:
+            abort(404)
+        else:
+            # Hack the dbt docs to render properly in an iframe
+            iframe_resizer_url = url_for(".static", filename="iframeResizer.contentWindow.min.js")
+            html = html.replace("</head>", f'{iframe_script}<script src="{iframe_resizer_url}"></script></head>', 1)
+            return html, 200, {"Content-Security-Policy": "frame-ancestors 'self'"}
+
+    @expose("/catalog.json")  # type: ignore[misc]
+    @has_access(MENU_ACCESS_PERMISSIONS)  # type: ignore[misc]
+    def catalog(self) -> Tuple[str, int, Dict[str, Any]]:
+        if dbt_docs_dir is None:
+            abort(404)
+        try:
+            data = open_file(op.join(dbt_docs_dir, "catalog.json"), conn_id=dbt_docs_conn_id)
+        except FileNotFoundError:
+            abort(404)
+        else:
+            return data, 200, {"Content-Type": "application/json"}
+
+    @expose("/manifest.json")  # type: ignore[misc]
+    @has_access(MENU_ACCESS_PERMISSIONS)  # type: ignore[misc]
+    def manifest(self) -> Tuple[str, int, Dict[str, Any]]:
+        if dbt_docs_dir is None:
+            abort(404)
+        try:
+            data = open_file(op.join(dbt_docs_dir, "manifest.json"), conn_id=dbt_docs_conn_id)
+        except FileNotFoundError:
+            abort(404)
+        else:
+            return data, 200, {"Content-Type": "application/json"}
+
+
+dbt_docs_view = DbtDocsView()
+
+
+class CosmosPlugin(AirflowPlugin):
+    name = "cosmos"
+    item = {
+        "name": "dbt Docs",
+        "category": "Browse",
+        "view": dbt_docs_view,
+        "href": conf.get("webserver", "base_url") + "/cosmos/dbt_docs",
+    }
+    appbuilder_views = [item]
+    listeners = [dag_run_listener]
diff --git a/docs/configuration/generating-docs.rst b/docs/configuration/generating-docs.rst
index 2360def395..ad9b926438 100644
--- a/docs/configuration/generating-docs.rst
+++ b/docs/configuration/generating-docs.rst
@@ -9,6 +9,10 @@ After generating the dbt docs, you can host them natively within Airflow via the
 
 Alternatively, many users choose to serve these docs on a separate static website. This is a great way to share your data models with a broad array of stakeholders.
 
+.. note::
+    The CosmosPlugin is not available for Airflow 3 yet as the compatibility is still being worked on. Hence, the dbt docs cannot be hosted and used in Airflow 3 yet.
+
+
 Cosmos offers two pre-built ways of generating and uploading dbt docs and a fallback option to run custom code after the docs are generated:
 
 - :class:`~cosmos.operators.DbtDocsS3Operator`: generates and uploads docs to a S3 bucket.
diff --git a/docs/configuration/hosting-docs.rst b/docs/configuration/hosting-docs.rst
index 7e531a4383..dc24113328 100644
--- a/docs/configuration/hosting-docs.rst
+++ b/docs/configuration/hosting-docs.rst
@@ -5,6 +5,10 @@ Hosting Docs
 
 dbt docs can be served directly from the `Apache Airflow® <https://airflow.apache.org/>`_ webserver with the Cosmos Airflow plugin, without requiring the user to set up anything outside of Airflow. This page describes how to host docs in the Airflow webserver directly, although some users may opt to host docs externally.
 
+.. note::
+    The CosmosPlugin is not available for Airflow 3 yet as the compatibility is still being worked on. Hence, the dbt docs cannot be hosted and used in Airflow 3 yet.
+
+
 Overview
 ~~~~~~~~
 
diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py
index 963df9f701..93b7d4b7c7 100644
--- a/tests/plugin/test_plugin.py
+++ b/tests/plugin/test_plugin.py
@@ -1,3 +1,18 @@
+from airflow import __version__ as airflow_version
+from packaging import version
+
+from cosmos.constants import _AIRFLOW3_MAJOR_VERSION
+
+# The Cosmos plugin is only loaded if the Airflow version is less than 3.0. This is because the plugin is incompatible
+# with Airflow 3.0 and above. Once the compatibility issue is resolved as part of
+# https://github.com/astronomer/astronomer-cosmos/issues/1587, we can remove this check and run the tests on
+# Airflow 3.0+.
+if version.parse(airflow_version).major >= _AIRFLOW3_MAJOR_VERSION:
+    import pytest
+
+    pytest.skip("Skipping plugin tests on Airflow 3.0+", allow_module_level=True)
+
+
 # dbt-core relies on Jinja2>3, whereas Flask<2 relies on an incompatible version of Jinja2.
 #
 # This discrepancy causes the automated integration tests to fail, as dbt-core is installed in the same
@@ -28,7 +43,7 @@
 import cosmos
 import cosmos.plugin
 import cosmos.settings
-from cosmos.plugin import (
+from cosmos.plugin.plugin_impl import (
     iframe_script,
     open_azure_file,
     open_file,
@@ -57,7 +72,7 @@ def module_monkeypatch():
 def app_within_astro_cloud(module_monkeypatch) -> FlaskClient:
     module_monkeypatch.setenv("ASTRONOMER_ENVIRONMENT", "cloud")
     importlib.reload(cosmos.settings)
-    importlib.reload(cosmos.plugin)
+    importlib.reload(cosmos.plugin.plugin_impl)
     importlib.reload(cosmos)
     initdb()
 
@@ -67,15 +82,15 @@ def app_within_astro_cloud(module_monkeypatch) -> FlaskClient:
 
     appbuilder.sm.check_authorization = lambda *args, **kwargs: True
 
-    if cosmos.plugin.dbt_docs_view not in appbuilder.baseviews:
+    if cosmos.plugin.plugin_impl.dbt_docs_view not in appbuilder.baseviews:
         # unregister blueprints registered in global context
         app._got_first_request = False  # Necessary for Airflow 2.4, Flask==2.2.2 & Flask-AppBuilder==4.1.3
         del app.blueprints["DbtDocsView"]
         keys_to_delete = [view_name for view_name in app.view_functions.keys() if view_name.startswith("DbtDocsView")]
         [app.view_functions.pop(view_name) for view_name in keys_to_delete]
 
-        appbuilder._check_and_init(cosmos.plugin.dbt_docs_view)
-        appbuilder.register_blueprint(cosmos.plugin.dbt_docs_view)
+        appbuilder._check_and_init(cosmos.plugin.plugin_impl.dbt_docs_view)
+        appbuilder.register_blueprint(cosmos.plugin.plugin_impl.dbt_docs_view)
 
     yield app.test_client()
 
@@ -91,9 +106,9 @@ def app() -> FlaskClient:
 
     appbuilder.sm.check_authorization = lambda *args, **kwargs: True
 
-    if cosmos.plugin.dbt_docs_view not in appbuilder.baseviews:
-        appbuilder._check_and_init(cosmos.plugin.dbt_docs_view)
-        appbuilder.register_blueprint(cosmos.plugin.dbt_docs_view)
+    if cosmos.plugin.plugin_impl.dbt_docs_view not in appbuilder.baseviews:
+        appbuilder._check_and_init(cosmos.plugin.plugin_impl.dbt_docs_view)
+        appbuilder.register_blueprint(cosmos.plugin.plugin_impl.dbt_docs_view)
 
     yield app.test_client()
 
@@ -102,7 +117,7 @@ def app() -> FlaskClient:
 
 @pytest.mark.integration
 def test_dbt_docs(monkeypatch, app):
-    monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir")
+    monkeypatch.setattr("cosmos.plugin.plugin_impl.dbt_docs_dir", "path/to/docs/dir")
 
     response = app.get("/cosmos/dbt_docs")
 
@@ -111,7 +126,7 @@ def test_dbt_docs(monkeypatch, app):
 
 
 @pytest.mark.integration
-@patch.object(cosmos.plugin, "open_file")
+@patch.object(cosmos.plugin.plugin_impl, "open_file")
 def test_dbt_docs_not_set_up(monkeypatch, app):
     response = app.get("/cosmos/dbt_docs")
 
@@ -120,12 +135,12 @@ def test_dbt_docs_not_set_up(monkeypatch, app):
 
 
 @pytest.mark.integration
-@patch.object(cosmos.plugin, "open_file")
+@patch.object(cosmos.plugin.plugin_impl, "open_file")
 @pytest.mark.parametrize("artifact", ["dbt_docs_index.html", "manifest.json", "catalog.json"])
 def test_dbt_docs_artifact(mock_open_file, monkeypatch, app, artifact):
-    monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir")
-    monkeypatch.setattr("cosmos.plugin.dbt_docs_conn_id", "mock_conn_id")
-    monkeypatch.setattr("cosmos.plugin.dbt_docs_index_file_name", "custom_index.html")
+    monkeypatch.setattr("cosmos.plugin.plugin_impl.dbt_docs_dir", "path/to/docs/dir")
+    monkeypatch.setattr("cosmos.plugin.plugin_impl.dbt_docs_conn_id", "mock_conn_id")
+    monkeypatch.setattr("cosmos.plugin.plugin_impl.dbt_docs_index_file_name", "custom_index.html")
 
     if artifact == "dbt_docs_index.html":
         mock_open_file.return_value = "<head></head><body></body>"
@@ -145,10 +160,10 @@ def test_dbt_docs_artifact(mock_open_file, monkeypatch, app, artifact):
 
 
 @pytest.mark.integration
-@patch.object(cosmos.plugin, "open_file")
+@patch.object(cosmos.plugin.plugin_impl, "open_file")
 @pytest.mark.parametrize("artifact", ["dbt_docs_index.html", "manifest.json", "catalog.json"])
 def test_dbt_docs_artifact_not_found(mock_open_file, monkeypatch, app, artifact):
-    monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir")
+    monkeypatch.setattr("cosmos.plugin.plugin_impl.dbt_docs_dir", "path/to/docs/dir")
     mock_open_file.side_effect = FileNotFoundError
 
     response = app.get(f"/cosmos/{artifact}")
@@ -175,7 +190,7 @@ def test_dbt_docs_artifact_missing(app, artifact):
     ],
 )
 def test_open_file_calls(path, open_file_callback):
-    with patch.object(cosmos.plugin, open_file_callback) as mock_callback:
+    with patch.object(cosmos.plugin.plugin_impl, open_file_callback) as mock_callback:
         mock_callback.return_value = "mock file contents"
         res = open_file(path, conn_id="mock_conn_id")
 
@@ -349,8 +364,8 @@ def test_open_file_local(mock_file):
     "url_path", ["/cosmos/dbt_docs", "/cosmos/dbt_docs_index.html", "/cosmos/catalog.json", "/cosmos/manifest.json"]
 )
 def test_has_access_with_permissions_outside_astro_does_not_include_custom_menu(url_path, app):
-    cosmos.plugin.dbt_docs_view.appbuilder.sm.check_authorization = MagicMock()
-    mock_check_auth = cosmos.plugin.dbt_docs_view.appbuilder.sm.check_authorization
+    cosmos.plugin.plugin_impl.dbt_docs_view.appbuilder.sm.check_authorization = MagicMock()
+    mock_check_auth = cosmos.plugin.plugin_impl.dbt_docs_view.appbuilder.sm.check_authorization
     app.get(url_path)
     assert mock_check_auth.call_args[0][0] == [("can_read", "Website")]
 
@@ -361,7 +376,7 @@ def test_has_access_with_permissions_outside_astro_does_not_include_custom_menu(
 )
 def test_has_access_with_permissions_in_astro_must_include_custom_menu(url_path, app_within_astro_cloud):
     app = app_within_astro_cloud
-    cosmos.plugin.dbt_docs_view.appbuilder.sm.check_authorization = MagicMock()
-    mock_check_auth = cosmos.plugin.dbt_docs_view.appbuilder.sm.check_authorization
+    cosmos.plugin.plugin_impl.dbt_docs_view.appbuilder.sm.check_authorization = MagicMock()
+    mock_check_auth = cosmos.plugin.plugin_impl.dbt_docs_view.appbuilder.sm.check_authorization
     app.get(url_path)
     assert mock_check_auth.call_args[0][0] == [("menu_access", "Custom Menu"), ("can_read", "Website")]


Reviews:
user: login: pankajkoti
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:58:28Z
user: login: tatiana
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-25T10:33:01Z
user: login: tatiana
body: Thanks, @pankajkoti , really excited for the AF3 support!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-25T10:33:19Z

Review comments:
diff_hunk: @@ -1,273 +1,11 @@
-import os.path as op
path: cosmos/plugin/__init__.py
user: login: pankajkoti
body: all of the existing content of this file has been copied over as-is to the new `plugin_impl.py` file in the same directory path
created_at: 2025-04-24T14:58:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,273 +1,11 @@
-import os.path as op
path: cosmos/plugin/__init__.py
user: login: tatiana
body: Do you know why Git didn't consider this a rename, but instead treated it as a new file?

Perhaps we could rename this just to `plugin/plugin.py` - its a bit ungly, but I feel impl is not adding much. Happy with alternatives as well :) 
created_at: 2025-04-25T10:33:01Z
author_association: COLLABORATOR

PR comments:
user: login: netlify[bot]
created_at: 2025-04-24T14:22:50Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *sunny-pastelito-5ecb04* canceled.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 0006a8fd3b1bb948ce291a71daefac91e0563e2b |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/sunny-pastelito-5ecb04/deploys/680a50d2f0be00000860f233 |
author_association: NONE
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-04-24T14:28:57Z
body: ## Deploying astronomer-cosmos with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>0006a8f</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://a54c6974.astronomer-cosmos.pages.dev'>https://a54c6974.astronomer-cosmos.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://disable-plugin-for-af3.astronomer-cosmos.pages.dev'>https://disable-plugin-for-af3.astronomer-cosmos.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/799f6c6de390adc4994d0b9621414a76/pages/view/astronomer-cosmos/a54c6974-ae04-4a55-9cc8-4826e2719b7a)

author_association: NONE
user: login: pankajkoti
created_at: 2025-04-24T15:08:21Z
body: I tested that the plugin continues to work with AF2
<img width="1508" alt="Screenshot 2025-04-24 at 8 37 43 PM" src="https://github.com/user-attachments/assets/cd918aa8-48ea-4c78-adc8-b8473ce37dcd" />

author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-24T15:17:48Z
body: ## [Codecov](https://app.codecov.io/gh/astronomer/astronomer-cosmos/pull/1692?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astronomer) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 97.43%. Comparing base [(`e6f7bb2`)](https://app.codecov.io/gh/astronomer/astronomer-cosmos/commit/e6f7bb2d5a323e5d31e7a0d8e82edc4b7746b7fe?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astronomer) to head [(`0006a8f`)](https://app.codecov.io/gh/astronomer/astronomer-cosmos/commit/0006a8fd3b1bb948ce291a71daefac91e0563e2b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astronomer).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #1692   +/-   ##
=======================================
  Coverage   97.42%   97.43%           
=======================================
  Files          80       81    +1     
  Lines        5019     5024    +5     
=======================================
+ Hits         4890     4895    +5     
  Misses        129      129           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/astronomer/astronomer-cosmos/pull/1692?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astronomer).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=astronomer).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: pankajkoti
created_at: 2025-04-25T10:24:27Z
body: This could help in the failing test for AF3 in https://github.com/astronomer/astronomer-cosmos/actions/runs/14662043590/job/41148487115?pr=1646 for PR #1646 
author_association: CONTRIBUTOR