From 11ff713b4430ca12eed412d0d1e13a2d41176650 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Tue, 20 Feb 2024 13:50:43 -0500 Subject: [PATCH] Add dbt docs natively in Airflow via plugin (#737) ## Description This PR adds a plugin (via the Airflow plugins entrypoint) that adds a menu item inside of `Browse` that renders the dbt docs: ![image](https://github.com/astronomer/astronomer-cosmos/assets/31971762/77b5e8d6-ada5-484c-b463-a01352ab61f6) And this is what it looks like. (This example is inside the dev docker compose): image The docs are rendered via an iframe with some additional hacks to make the page render in a user friendly way. I chose an iframe over vendoring the `index.html` in the templates for a few reasons, but mostly to support custom `{% block __overview__ %}` text. However, extracting the text from `index.html` and rendering it in a custom page is certainly an option too. The dbt docs are specified in the Airflow config with the following parameters: ```ini [cosmos] dbt_docs_dir = path/to/docs/here dbt_docs_conn_id = my_conn_id ``` Note that the path can be a link to any of the following: - S3 - Azure Blob Storage - Google Cloud Storage - HTTP/HTTPS - Local storage This is designed to work with the operators that dump the dbt docs, and the documentation changes I added make that clear. Lastly, if docs are not hooked up, a message comes up telling the user that they should set their dbt docs up: image ### Current limitations - Most importantly, **I need help testing the S3 / Azure / GCS integrations.** I _think_ I got them right but I'll need someone to actually try them. - **I also wouldn't mind some help testing the UI on more browsers.** I've tested both Firefox and Chrome. - **The iframe hack is less than ideal; I would preferably want the dbt docs to have a fixed height.** So instead of using the scroll bar of the Airflow UI, use the scroll bar of the dbt docs UI. The issue is basically that I am not an HTML/CSS/JavaScript person. I don't think there is any reason this shouldn't be possible, so I can continue to look into this as the PR is reviewed, or someone else can just do it for me. - I cannot run tests locally (lots of issues, mostly the databricks DAG in `dev/dags/` fails locally), so I actually have no idea whether the test suite works. I was just planning on letting Github Actions take a stab at it. ### API Decisions The core maintainers of the repo should provide some feedback on a few high level API decisions: - **Config variable names:** Let me know if `dbt_docs_dir` and `dbt_docs_conn_id` are appropriate names. Other names could be like, `dbt_docs_path`, or `dbt_docs_dir_conn_id`, or `dbt_docs_path_conn_id`, etc. - **Location in UI:** I entertained two ideas: (a) Adding a menu button called Cosmos with dbt docs underneath. (b) Adding it under browse. Ultimately I decided on option 2. ## Related Issue(s) Closes #571. ## Breaking Change? This PR should not cause any breaking changes. ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Tatiana Al-Chueyr Co-authored-by: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- cosmos/plugin/__init__.py | 202 ++++++++++++++++ .../static/iframeResizer.contentWindow.min.js | 9 + cosmos/plugin/static/iframeResizer.min.js | 8 + cosmos/plugin/templates/dbt_docs.html | 15 ++ .../plugin/templates/dbt_docs_not_set_up.html | 9 + dev/dags/dbt/jaffle_shop/.gitignore | 1 + dev/docker-compose.yaml | 1 + .../location_of_dbt_docs_in_airflow.png | Bin 0 -> 48804 bytes docs/configuration/generating-docs.rst | 4 +- docs/configuration/hosting-docs.rst | 127 ++++++++++ docs/configuration/index.rst | 1 + pyproject.toml | 3 + tests/plugin/__init__.py | 0 tests/plugin/test_plugin.py | 223 ++++++++++++++++++ 15 files changed, 603 insertions(+), 2 deletions(-) create mode 100644 cosmos/plugin/__init__.py create mode 100644 cosmos/plugin/static/iframeResizer.contentWindow.min.js create mode 100644 cosmos/plugin/static/iframeResizer.min.js create mode 100644 cosmos/plugin/templates/dbt_docs.html create mode 100644 cosmos/plugin/templates/dbt_docs_not_set_up.html create mode 100644 docs/_static/location_of_dbt_docs_in_airflow.png create mode 100644 docs/configuration/hosting-docs.rst create mode 100644 tests/plugin/__init__.py create mode 100644 tests/plugin/test_plugin.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be9cb2642..146e07743 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: types: [text] args: - --exclude-file=tests/sample/manifest_model_version.json - - --skip=**/manifest.json + - --skip=**/manifest.json,**.min.js - -L connexion,aci - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py new file mode 100644 index 000000000..48061b254 --- /dev/null +++ b/cosmos/plugin/__init__.py @@ -0,0 +1,202 @@ +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 + + +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(conn_id: Optional[str], path: str) -> str: + from airflow.providers.amazon.aws.hooks.s3 import S3Hook + + if conn_id is None: + conn_id = S3Hook.default_conn_name + + hook = S3Hook(aws_conn_id=conn_id) + bucket, key = bucket_and_key(path) + content = hook.read_key(key=key, bucket_name=bucket) + return content # type: ignore[no-any-return] + + +def open_gcs_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.google.cloud.hooks.gcs import GCSHook + + if conn_id is None: + conn_id = GCSHook.default_conn_name + + hook = GCSHook(gcp_conn_id=conn_id) + bucket, blob = bucket_and_key(path) + content = hook.download(bucket_name=bucket, object_name=blob) + return content.decode("utf-8") # type: ignore[no-any-return] + + +def open_azure_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.microsoft.azure.hooks.wasb import WasbHook + + if conn_id is None: + conn_id = WasbHook.default_conn_name + + hook = WasbHook(wasb_conn_id=conn_id) + + container, blob = bucket_and_key(path) + content = hook.read_file(container_name=container, blob_name=blob) + return content # type: ignore[no-any-return] + + +def open_http_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.http.hooks.http import HttpHook + + if conn_id is None: + conn_id = "" + + hook = HttpHook(method="GET", http_conn_id=conn_id) + res = hook.run(endpoint=path) + hook.check_response(res) + return res.text # type: ignore[no-any-return] + + +def open_file(path: str) -> str: + """Retrieve a file from http, https, gs, s3, or wasb.""" + conn_id: Optional[str] = conf.get("cosmos", "dbt_docs_conn_id", fallback=None) + + if path.strip().startswith("s3://"): + return open_s3_file(conn_id=conn_id, path=path) + elif path.strip().startswith("gs://"): + return open_gcs_file(conn_id=conn_id, path=path) + elif path.strip().startswith("wasb://"): + return open_azure_file(conn_id=conn_id, path=path) + elif path.strip().startswith("http://") or path.strip().startswith("https://"): + return open_http_file(conn_id=conn_id, path=path) + else: + with open(path) as f: + content = f.read() + return content # type: ignore[no-any-return] + + +iframe_script = """ + +""" + + +class DbtDocsView(AirflowBaseView): + 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([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) + def dbt_docs(self) -> str: + if conf.get("cosmos", "dbt_docs_dir", fallback=None) 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([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) + def dbt_docs_index(self) -> str: + docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) + if docs_dir is None: + abort(404) + html = open_file(op.join(docs_dir, "index.html")) + # Hack the dbt docs to render properly in an iframe + iframe_resizer_url = url_for(".static", filename="iframeResizer.contentWindow.min.js") + html = html.replace("", f'{iframe_script}', 1) + return html + + @expose("/catalog.json") # type: ignore[misc] + @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) + def catalog(self) -> Tuple[str, int, Dict[str, Any]]: + docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) + if docs_dir is None: + abort(404) + data = open_file(op.join(docs_dir, "catalog.json")) + return data, 200, {"Content-Type": "application/json"} + + @expose("/manifest.json") # type: ignore[misc] + @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) + def manifest(self) -> Tuple[str, int, Dict[str, Any]]: + docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) + if docs_dir is None: + abort(404) + data = open_file(op.join(docs_dir, "manifest.json")) + return data, 200, {"Content-Type": "application/json"} + + +dbt_docs_view = DbtDocsView() + + +class CosmosPlugin(AirflowPlugin): + name = "cosmos" + appbuilder_views = [{"name": "dbt Docs", "category": "Browse", "view": dbt_docs_view}] diff --git a/cosmos/plugin/static/iframeResizer.contentWindow.min.js b/cosmos/plugin/static/iframeResizer.contentWindow.min.js new file mode 100644 index 000000000..914161c09 --- /dev/null +++ b/cosmos/plugin/static/iframeResizer.contentWindow.min.js @@ -0,0 +1,9 @@ +/*! iFrame Resizer (iframeSizer.contentWindow.min.js) - v4.3.5 - 2023-03-08 + * Desc: Include this file in any page being loaded into an iframe + * to force the iframe to resize to the content size. + * Requires: iframeResizer.min.js on host page. + * Copyright: (c) 2023 David J. Bradshaw - dave@bradshaw.net + * License: MIT + */ +!function(a){if("undefined"!=typeof window){var r=!0,P="",u=0,c="",s=null,D="",d=!1,j={resize:1,click:1},l=128,q=!0,f=1,n="bodyOffset",m=n,H=!0,W="",h={},g=32,B=null,p=!1,v=!1,y="[iFrameSizer]",J=y.length,w="",U={max:1,min:1,bodyScroll:1,documentElementScroll:1},b="child",V=!0,X=window.parent,T="*",E=0,i=!1,Y=null,O=16,S=1,K="scroll",M=K,Q=window,G=function(){x("onMessage function not defined")},Z=function(){},$=function(){},_={height:function(){return x("Custom height calculation function not defined"),document.documentElement.offsetHeight},width:function(){return x("Custom width calculation function not defined"),document.body.scrollWidth}},ee={},te=!1;try{var ne=Object.create({},{passive:{get:function(){te=!0}}});window.addEventListener("test",ae,ne),window.removeEventListener("test",ae,ne)}catch(e){}var oe,o,I,ie,N,A,C={bodyOffset:function(){return document.body.offsetHeight+ye("marginTop")+ye("marginBottom")},offset:function(){return C.bodyOffset()},bodyScroll:function(){return document.body.scrollHeight},custom:function(){return _.height()},documentElementOffset:function(){return document.documentElement.offsetHeight},documentElementScroll:function(){return document.documentElement.scrollHeight},max:function(){return Math.max.apply(null,e(C))},min:function(){return Math.min.apply(null,e(C))},grow:function(){return C.max()},lowestElement:function(){return Math.max(C.bodyOffset()||C.documentElementOffset(),we("bottom",Te()))},taggedElement:function(){return be("bottom","data-iframe-height")}},z={bodyScroll:function(){return document.body.scrollWidth},bodyOffset:function(){return document.body.offsetWidth},custom:function(){return _.width()},documentElementScroll:function(){return document.documentElement.scrollWidth},documentElementOffset:function(){return document.documentElement.offsetWidth},scroll:function(){return Math.max(z.bodyScroll(),z.documentElementScroll())},max:function(){return Math.max.apply(null,e(z))},min:function(){return Math.min.apply(null,e(z))},rightMostElement:function(){return we("right",Te())},taggedElement:function(){return be("right","data-iframe-width")}},re=(oe=Ee,N=null,A=0,function(){var e=Date.now(),t=O-(e-(A=A||e));return o=this,I=arguments,t<=0||Ok[r]["max"+e])throw new Error("Value for min"+e+" can not be greater than max"+e)}}function h(e,n){null===i&&(i=setTimeout(function(){i=null,e()},n))}function e(){"hidden"!==document.visibilityState&&(O("document","Trigger event: Visibility change"),h(function(){b("Tab Visible","resize")},16))}function b(i,t){Object.keys(k).forEach(function(e){var n;k[n=e]&&"parent"===k[n].resizeFrom&&k[n].autoResize&&!k[n].firstRun&&A(i,t,k[e].iframe,e)})}function y(){F(window,"message",w),F(window,"resize",function(){var e;O("window","Trigger event: "+(e="resize")),h(function(){b("Window "+e,"resize")},16)}),F(document,"visibilitychange",e),F(document,"-webkit-visibilitychange",e)}function n(){function t(e,n){if(n){if(!n.tagName)throw new TypeError("Object is not a valid DOM element");if("IFRAME"!==n.tagName.toUpperCase())throw new TypeError("Expected + +{% endblock %} diff --git a/cosmos/plugin/templates/dbt_docs_not_set_up.html b/cosmos/plugin/templates/dbt_docs_not_set_up.html new file mode 100644 index 000000000..1fcc6ef7f --- /dev/null +++ b/cosmos/plugin/templates/dbt_docs_not_set_up.html @@ -0,0 +1,9 @@ +{% extends base_template %} +{% block content %} +

⚠️ Your dbt docs are not set up yet! ⚠️

+ +

+ Read the Astronomer Cosmos docs for information on how to set up dbt docs. +

+ +{% endblock %} diff --git a/dev/dags/dbt/jaffle_shop/.gitignore b/dev/dags/dbt/jaffle_shop/.gitignore index 49f147cb9..45d294b9a 100644 --- a/dev/dags/dbt/jaffle_shop/.gitignore +++ b/dev/dags/dbt/jaffle_shop/.gitignore @@ -2,3 +2,4 @@ target/ dbt_packages/ logs/ +!target/manifest.json diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 23b012d15..5345f4b13 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -10,6 +10,7 @@ x-airflow-common: environment: &airflow-common-env DB_BACKEND: postgres + AIRFLOW__COSMOS__DBT_DOCS_DIR: http://cosmos-docs.s3-website-us-east-1.amazonaws.com/ AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:pg_password@postgres:5432/airflow AIRFLOW__CORE__FERNET_KEY: '' diff --git a/docs/_static/location_of_dbt_docs_in_airflow.png b/docs/_static/location_of_dbt_docs_in_airflow.png new file mode 100644 index 0000000000000000000000000000000000000000..348a53c8ea2b9f894a8f8070acc6e1a6ff354cb6 GIT binary patch literal 48804 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 literal 0 HcmV?d00001 diff --git a/docs/configuration/generating-docs.rst b/docs/configuration/generating-docs.rst index 6112ebcee..54ec80fc9 100644 --- a/docs/configuration/generating-docs.rst +++ b/docs/configuration/generating-docs.rst @@ -5,7 +5,9 @@ Generating Docs dbt allows you to generate static documentation on your models, tables, and more. You can read more about it in the `official dbt documentation `_. For an example of what the docs look like with the ``jaffle_shop`` project, check out `this site `_. -Many users choose to generate and serve these docs on a static website. This is a great way to share your data models with your team and other stakeholders. +After generating the dbt docs, you can host them natively within Airflow via the Cosmos Airflow plugin; see `Hosting Docs `__ for more information. + +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. 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: diff --git a/docs/configuration/hosting-docs.rst b/docs/configuration/hosting-docs.rst new file mode 100644 index 000000000..5143a9f67 --- /dev/null +++ b/docs/configuration/hosting-docs.rst @@ -0,0 +1,127 @@ +.. hosting-docs: + +Hosting Docs +============ + +dbt docs can be served directly from the Apache Airflow 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. + +Overview +~~~~~~~~ + +The dbt docs are available in the Airflow menu under ``Browse > dbt docs``: + +.. image:: /_static/location_of_dbt_docs_in_airflow.png + :alt: Airflow UI - Location of dbt docs in menu + :align: center + +In order to access the dbt docs, you must specify the following config variables: + +- ``cosmos.dbt_docs_dir``: A path to where the docs are being hosted. +- (Optional) ``cosmos.dbt_docs_conn_id``: A conn ID to use for a cloud storage deployment. If not specified _and_ the URI points to a cloud storage platform, then the default conn ID for the AWS/Azure/GCP hook will be used. + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = path/to/docs/here + dbt_docs_conn_id = my_conn_id + +or as an environment variable: + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="path/to/docs/here" + AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="my_conn_id" + +The path can be either a folder in the local file system the webserver is running on, or a URI to a cloud storage platform (S3, GCS, Azure). + +Host from Cloud Storage +~~~~~~~~~~~~~~~~~~~~~~~ + +For typical users, the recommended setup for hosting dbt docs would look like this: + +1. Generate the docs via one of Cosmos' pre-built operators for generating dbt docs (see `Generating Docs `__ for more information) +2. Wherever you dumped the docs, set your ``cosmos.dbt_docs_dir`` to that location. +3. If you want to use a conn ID other than the default connection, set your ``cosmos.dbt_docs_conn_id``. Otherwise, leave this blank. + +AWS S3 Example +^^^^^^^^^^^^^^ + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = s3://my-bucket/path/to/docs + dbt_docs_conn_id = aws_default + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="s3://my-bucket/path/to/docs" + AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="aws_default" + +Google Cloud Storage Example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = gs://my-bucket/path/to/docs + dbt_docs_conn_id = google_cloud_default + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="gs://my-bucket/path/to/docs" + AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="google_cloud_default" + +Azure Blob Storage Example +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = wasb://my-container/path/to/docs + dbt_docs_conn_id = wasb_default + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="wasb://my-container/path/to/docs" + AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="wasb_default" + +Host from Local Storage +~~~~~~~~~~~~~~~~~~~~~~~ + +By default, Cosmos will not generate docs on the fly. Local storage only works if you are pre-compiling your dbt project before deployment. + +If your Airflow deployment process involves running ``dbt compile``, you will also want to add ``dbt docs generate`` to your deployment process as well to generate all the artifacts necessary to run the dbt docs from local storage. + +By default, dbt docs are generated in the ``target`` folder; so that will also be your docs folder by default. + +For example, if your dbt project directory is ``/usr/local/airflow/dags/my_dbt_project``, then by default your dbt docs directory will be ``/usr/local/airflow/dags/my_dbt_project/target``: + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = /usr/local/airflow/dags/my_dbt_project/target + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="/usr/local/airflow/dags/my_dbt_project/target" + +Using docs out of local storage has the downside that some values in the dbt docs can become stale unless the docs are periodically refreshed and redeployed: + +- Counts of the numbers of rows. +- The compiled SQL for incremental models before and after the first run. + +Host from HTTP/HTTPS +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = https://my-site.com/path/to/docs + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="https://my-site.com/path/to/docs" + + +You do not need to set a ``dbt_docs_conn_id`` when using HTTP/HTTPS. +If you do set the ``dbt_docs_conn_id``, then the ``HttpHook`` will be used. diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index 8c282be03..919ed9b1e 100644 --- a/docs/configuration/index.rst +++ b/docs/configuration/index.rst @@ -16,6 +16,7 @@ Cosmos offers a number of configuration options to customize its behavior. For m Parsing Methods Configuring Lineage Generating Docs + Hosting Docs Scheduling Testing Behavior Selecting & Excluding diff --git a/pyproject.toml b/pyproject.toml index 522431da7..7758f9669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,9 @@ azure-container-instance = [ [project.entry-points.cosmos] provider_info = "cosmos:get_provider_info" +[project.entry-points."airflow.plugins"] +cosmos = "cosmos.plugin:CosmosPlugin" + [project.urls] Homepage = "https://github.com/astronomer/astronomer-cosmos" Documentation = "https://astronomer.github.io/astronomer-cosmos" diff --git a/tests/plugin/__init__.py b/tests/plugin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py new file mode 100644 index 000000000..df33ae13a --- /dev/null +++ b/tests/plugin/test_plugin.py @@ -0,0 +1,223 @@ +# 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 +# environment as apache-airflow. +# +# We can get around this by patching the jinja2 namespace to include the deprecated objects: +try: + import flask # noqa: F401 +except ImportError: + import markupsafe + import jinja2 + + jinja2.Markup = markupsafe.Markup + jinja2.escape = markupsafe.escape + +from unittest.mock import mock_open, patch, MagicMock, PropertyMock + +import sys +import pytest +from airflow.configuration import conf +from airflow.exceptions import AirflowConfigException +from airflow.utils.db import initdb, resetdb +from airflow.www.app import cached_app +from airflow.www.extensions.init_appbuilder import AirflowAppBuilder +from flask.testing import FlaskClient + +import cosmos.plugin + +from cosmos.plugin import ( + dbt_docs_view, + iframe_script, + open_gcs_file, + open_azure_file, + open_http_file, + open_s3_file, + open_file, +) + + +original_conf_get = conf.get + + +def _get_text_from_response(response) -> str: + # Airflow < 2.4 uses an old version of Werkzeug that does not have Response.text. + if not hasattr(response, "text"): + return response.get_data(as_text=True) + else: + return response.text + + +@pytest.fixture(scope="module") +def app() -> FlaskClient: + initdb() + + app = cached_app(testing=True) + appbuilder: AirflowAppBuilder = app.extensions["appbuilder"] + + appbuilder.sm.check_authorization = lambda *args, **kwargs: True + + if dbt_docs_view not in appbuilder.baseviews: + appbuilder._check_and_init(dbt_docs_view) + appbuilder.register_blueprint(dbt_docs_view) + + yield app.test_client() + + resetdb(skip_init=True) + + +def test_dbt_docs(monkeypatch, app): + def conf_get(section, key, *args, **kwargs): + if section == "cosmos" and key == "dbt_docs_dir": + return "path/to/docs/dir" + else: + return original_conf_get(section, key, *args, **kwargs) + + monkeypatch.setattr(conf, "get", conf_get) + + response = app.get("/cosmos/dbt_docs") + + assert response.status_code == 200 + assert "