From 020e9e4e7f904fa346339241a654890451cc3a5a Mon Sep 17 00:00:00 2001 From: dwreeves Date: Sat, 2 Dec 2023 18:43:27 -0500 Subject: [PATCH 01/13] add dbt docs hosting support --- .pre-commit-config.yaml | 2 +- cosmos/plugin/__init__.py | 207 ++++++++++++++++++ .../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 | 111 ++++++++++ docs/configuration/index.rst | 1 + pyproject.toml | 3 + tests/plugin/__init__.py | 0 tests/plugin/test_plugin.py | 86 ++++++++ 15 files changed, 455 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 890afb0b74..03641aa666 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 - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py new file mode 100644 index 0000000000..6ea90798d2 --- /dev/null +++ b/cosmos/plugin/__init__.py @@ -0,0 +1,207 @@ +import os.path as op +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlsplit + +from airflow.configuration import conf +from airflow.exceptions import AirflowConfigException, AirflowNotFoundException +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_file(path: str) -> str: # noqa: C901 + """Retrieve a file from http, https, gs, s3, or wasb.""" + try: + conn_id: Optional[str] = conf.get("cosmos", "dbt_docs_conn_id") + except AirflowConfigException: + conn_id = None + + if path.strip().startswith("s3://"): + from airflow.providers.amazon.aws.hooks.s3 import S3Hook + + if conn_id is None: + hook = S3Hook() + else: + hook = S3Hook(aws_conn_id=conn_id) + bucket, key = hook.parse_s3_url(path) + content = hook.read_key(key=key, bucket_name=bucket) + + return content # type: ignore[no-any-return] + elif path.strip().startswith("gs://"): + from airflow.providers.google.cloud.hooks.gcs import GCSHook + + if conn_id is None: + hook = GCSHook() + else: + 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] + + elif path.strip().startswith("wasb://"): + from airflow.providers.microsoft.azure.hooks.wasb import WasbHook + + if conn_id is None: + hook = WasbHook() + else: + 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] + + elif path.strip().startswith("http://") or path.strip().startswith("https://"): + from airflow.providers.http.hooks.http import HttpHook + + if conn_id is None: + try: + HttpHook.get_connection(conn_id=HttpHook.default_conn_name) + hook = HttpHook(method="GET") + except AirflowNotFoundException: + hook = HttpHook(method="GET", http_conn_id="") + else: + 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] + + 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: + try: + conf.get("cosmos", "dbt_docs_dir") + except AirflowConfigException: + 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: + try: + docs_dir = conf.get("cosmos", "dbt_docs_dir") + html = open_file(op.join(docs_dir, "index.html")) + except (FileNotFoundError, AirflowConfigException): + 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("", 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]]: + try: + docs_dir = conf.get("cosmos", "dbt_docs_dir") + data = open_file(op.join(docs_dir, "catalog.json")) + except (FileNotFoundError, AirflowConfigException): + abort(404) + else: + 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]]: + try: + docs_dir = conf.get("cosmos", "dbt_docs_dir") + data = open_file(op.join(docs_dir, "manifest.json")) + except (FileNotFoundError, AirflowConfigException): + abort(404) + else: + 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 0000000000..914161c09f --- /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 0000000000..1fcc6ef7f3 --- /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 49f147cb98..45d294b9af 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 23b012d153..5345f4b134 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 88459fd14e..ab6727412c 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 0000000000..698af510a6 --- /dev/null +++ b/docs/configuration/hosting-docs.rst @@ -0,0 +1,111 @@ +.. 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="s3://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. (For example, you are setting the + +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. diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index 8c282be030..919ed9b1e5 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 df304c3b54..69ffc50af8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,9 @@ kubernetes = [ [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 0000000000..e69de29bb2 diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py new file mode 100644 index 0000000000..fe2dfbeb73 --- /dev/null +++ b/tests/plugin/test_plugin.py @@ -0,0 +1,86 @@ +from unittest.mock import patch + +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 + +from cosmos.plugin import dbt_docs_view, iframe_script + + +original_conf_get = conf.get + + +@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) + + +@patch("cosmos.plugin.open_file") +def test_dbt_docs(mock_open_file, 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) + + response = app.get("/cosmos/dbt_docs") + assert response.status_code == 200 + + +@patch("cosmos.plugin.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): + 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) + + if artifact == "dbt_docs_index.html": + mock_open_file.return_value = "" + else: + mock_open_file.return_value = "{}" + + response = app.get(f"/cosmos/{artifact}") + + mock_open_file.assert_called_once() + assert response.status_code == 200 + if artifact == "dbt_docs_index.html": + # Airflow < 2.4 uses an old version of Werkzeug that does not have Response.text. + if not hasattr(response, "text"): + assert iframe_script in response.get_data(as_text=True) + else: + assert iframe_script in response.text + + +@pytest.mark.parametrize("artifact", ["dbt_docs_index.html", "manifest.json", "catalog.json"]) +def test_dbt_docs_artifact_missing(app, artifact, monkeypatch): + def conf_get(section, key, *args, **kwargs): + if section == "cosmos": + raise AirflowConfigException + else: + return original_conf_get(section, key, *args, **kwargs) + + monkeypatch.setattr(conf, "get", conf_get) + + response = app.get(f"/cosmos/{artifact}") + assert response.status_code == 404 From f3be3dd770cfdb1fc1c6f7aaaa22caae4d44e85f Mon Sep 17 00:00:00 2001 From: dwreeves Date: Mon, 4 Dec 2023 20:28:07 -0500 Subject: [PATCH 02/13] patch --- tests/plugin/test_plugin.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index fe2dfbeb73..16d6af2188 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -1,3 +1,19 @@ +# 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 patch import pytest From e589035cdc638c0201b0b6b41a1945851bb3a9c0 Mon Sep 17 00:00:00 2001 From: dwreeves Date: Wed, 6 Dec 2023 03:53:06 -0500 Subject: [PATCH 03/13] update tests for more coverage --- cosmos/plugin/__init__.py | 119 +++++++++++++++++++----------------- tests/plugin/test_plugin.py | 86 ++++++++++++++++++++++++-- 2 files changed, 145 insertions(+), 60 deletions(-) diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py index 6ea90798d2..be5a5caacf 100644 --- a/cosmos/plugin/__init__.py +++ b/cosmos/plugin/__init__.py @@ -19,63 +19,75 @@ def bucket_and_key(path: str) -> Tuple[str, str]: return bucket, key -def open_file(path: str) -> str: # noqa: C901 - """Retrieve a file from http, https, gs, s3, or wasb.""" - try: - conn_id: Optional[str] = conf.get("cosmos", "dbt_docs_conn_id") - except AirflowConfigException: - conn_id = None +def open_s3_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.amazon.aws.hooks.s3 import S3Hook - if path.strip().startswith("s3://"): - from airflow.providers.amazon.aws.hooks.s3 import S3Hook + if conn_id is None: + conn_id = S3Hook.default_conn_name - if conn_id is None: - hook = S3Hook() - else: - hook = S3Hook(aws_conn_id=conn_id) - bucket, key = hook.parse_s3_url(path) - content = hook.read_key(key=key, bucket_name=bucket) + 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] - return content # type: ignore[no-any-return] - elif path.strip().startswith("gs://"): - from airflow.providers.google.cloud.hooks.gcs import GCSHook - if conn_id is None: - hook = GCSHook() - else: - hook = GCSHook(gcp_conn_id=conn_id) +def open_gcs_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.google.cloud.hooks.gcs import GCSHook - 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] + if conn_id is None: + conn_id = GCSHook.default_conn_name - elif path.strip().startswith("wasb://"): - from airflow.providers.microsoft.azure.hooks.wasb import WasbHook + 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] - if conn_id is None: - hook = WasbHook() - else: - 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_azure_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.microsoft.azure.hooks.wasb import WasbHook - elif path.strip().startswith("http://") or path.strip().startswith("https://"): - from airflow.providers.http.hooks.http import HttpHook - - if conn_id is None: - try: - HttpHook.get_connection(conn_id=HttpHook.default_conn_name) - hook = HttpHook(method="GET") - except AirflowNotFoundException: - hook = HttpHook(method="GET", http_conn_id="") + 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: + try: + HttpHook.get_connection(conn_id=HttpHook.default_conn_name) + except AirflowNotFoundException: + hook = HttpHook(method="GET", http_conn_id="") else: - 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] + hook = HttpHook(method="GET") + else: + 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.""" + try: + conn_id: Optional[str] = conf.get("cosmos", "dbt_docs_conn_id") + except AirflowConfigException: + conn_id = 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() @@ -170,11 +182,10 @@ def dbt_docs_index(self) -> str: html = open_file(op.join(docs_dir, "index.html")) except (FileNotFoundError, AirflowConfigException): 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("", f'{iframe_script}', 1) - return 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)]) @@ -184,8 +195,7 @@ def catalog(self) -> Tuple[str, int, Dict[str, Any]]: data = open_file(op.join(docs_dir, "catalog.json")) except (FileNotFoundError, AirflowConfigException): abort(404) - else: - return data, 200, {"Content-Type": "application/json"} + return data, 200, {"Content-Type": "application/json"} @expose("/manifest.json") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) @@ -195,8 +205,7 @@ def manifest(self) -> Tuple[str, int, Dict[str, Any]]: data = open_file(op.join(docs_dir, "manifest.json")) except (FileNotFoundError, AirflowConfigException): abort(404) - else: - return data, 200, {"Content-Type": "application/json"} + return data, 200, {"Content-Type": "application/json"} dbt_docs_view = DbtDocsView() diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 16d6af2188..259650c154 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -13,9 +13,9 @@ jinja2.Markup = markupsafe.Markup jinja2.escape = markupsafe.escape +from unittest.mock import patch, MagicMock -from unittest.mock import patch - +import sys import pytest from airflow.configuration import conf from airflow.exceptions import AirflowConfigException @@ -24,7 +24,17 @@ from airflow.www.extensions.init_appbuilder import AirflowAppBuilder from flask.testing import FlaskClient -from cosmos.plugin import dbt_docs_view, iframe_script +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 @@ -48,7 +58,7 @@ def app() -> FlaskClient: resetdb(skip_init=True) -@patch("cosmos.plugin.open_file") +@patch.object(cosmos.plugin, "open_file") def test_dbt_docs(mock_open_file, monkeypatch, app): def conf_get(section, key, *args, **kwargs): if section == "cosmos" and key == "dbt_docs_dir": @@ -57,10 +67,11 @@ def conf_get(section, key, *args, **kwargs): return original_conf_get(section, key, *args, **kwargs) response = app.get("/cosmos/dbt_docs") + assert response.status_code == 200 -@patch("cosmos.plugin.open_file") +@patch.object(cosmos.plugin, "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): def conf_get(section, key, *args, **kwargs): @@ -100,3 +111,68 @@ def conf_get(section, key, *args, **kwargs): response = app.get(f"/cosmos/{artifact}") assert response.status_code == 404 + + +@pytest.mark.parametrize( + "path,open_file_callback", + [ + ("s3://my-bucket/my/path/", "open_s3_file"), + ("gs://my-bucket/my/path/", "open_gcs_file"), + ("wasb://my-bucket/my/path/", "open_azure_file"), + ("https://my-bucket/my/path/", "open_http_file"), + ], +) +def test_open_file_calls(path, open_file_callback, monkeypatch): + def conf_get(section, key, *args, **kwargs): + if section == "cosmos" and key == "dbt_docs_conn_id": + return "mock_conn_id" + else: + return original_conf_get(section, key, *args, **kwargs) + + monkeypatch.setattr(conf, "get", conf_get) + + with patch.object(cosmos.plugin, open_file_callback) as mock_callback: + mock_callback.return_value = "mock file contents" + res = open_file(path) + + mock_callback.assert_called_with(conn_id="mock_conn_id", path=path) + assert res == "mock file contents" + + +def test_open_s3_file(): + mock_module = MagicMock() + with patch.dict(sys.modules, {"airflow.providers.amazon.aws.hooks.s3": mock_module}): + mock_hook = mock_module.S3Hook.return_value = MagicMock() + mock_hook.read_key.return_value = "mock file contents" + + res = open_s3_file(conn_id="mock_conn_id", path="s3://mock-path/to/docs") + + mock_module.S3Hook.assert_called_once_with(aws_conn_id="mock_conn_id") + mock_hook.read_key.assert_called_once_with(bucket_name="mock-path", key="to/docs") + assert res == "mock file contents" + + +def test_open_gcs_file(): + mock_module = MagicMock() + with patch.dict(sys.modules, {"airflow.providers.google.cloud.hooks.gcs": mock_module}): + mock_hook = mock_module.GCSHook.return_value = MagicMock() + mock_hook.download.return_value = b"mock file contents" + + res = open_gcs_file(conn_id="mock_conn_id", path="gs://mock-path/to/docs") + + mock_module.GCSHook.assert_called_once_with(gcp_conn_id="mock_conn_id") + mock_hook.download.assert_called_once_with(bucket_name="mock-path", object_name="to/docs") + assert res == "mock file contents" + + +def test_open_azure_file(): + mock_module = MagicMock() + with patch.dict(sys.modules, {"airflow.providers.microsoft.azure.hooks.wasb": mock_module}): + mock_hook = mock_module.WasbHook.return_value = MagicMock() + mock_hook.read_file.return_value = "mock file contents" + + res = open_azure_file(conn_id="mock_conn_id", path="wasb://mock-path/to/docs") + + mock_module.WasbHook.assert_called_once_with(wasb_conn_id="mock_conn_id") + mock_hook.read_file.assert_called_once_with(container_name="mock-path", blob_name="to/docs") + assert res == "mock file contents" From 92ac35be891fe89b386d6adbea89e1f9c592c913 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 6 Dec 2023 09:05:41 -0500 Subject: [PATCH 04/13] Update test_plugin.py --- tests/plugin/test_plugin.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 259650c154..303abd07f7 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -119,6 +119,7 @@ def conf_get(section, key, *args, **kwargs): ("s3://my-bucket/my/path/", "open_s3_file"), ("gs://my-bucket/my/path/", "open_gcs_file"), ("wasb://my-bucket/my/path/", "open_azure_file"), + ("http://my-bucket/my/path/", "open_http_file"), ("https://my-bucket/my/path/", "open_http_file"), ], ) @@ -176,3 +177,18 @@ def test_open_azure_file(): mock_module.WasbHook.assert_called_once_with(wasb_conn_id="mock_conn_id") mock_hook.read_file.assert_called_once_with(container_name="mock-path", blob_name="to/docs") assert res == "mock file contents" + + +def test_open_http_file(): + mock_module = MagicMock() + with patch.dict(sys.modules, {"airflow.providers.http.hooks.http": mock_module}): + mock_hook = mock_module.HttpHook.return_value = MagicMock() + mock_response = mock_hook.run.return_value = MagicMock() + mock_hook.check_response.return_value = mock_response + mock_response.text = "mock file contents" + + res = open_http_file(conn_id="mock_conn_id", path="http://mock-path/to/docs") + + mock_module.HttpHook.assert_called_once_with(http_conn_id="mock_conn_id") + mock_hook.get.assert_called_once_with(endpoint=path) + assert res == "mock file contents" From fe92ff85360a18a15b955821b81f80d4115822bd Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 6 Dec 2023 09:09:17 -0500 Subject: [PATCH 05/13] Update test_plugin.py --- tests/plugin/test_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 303abd07f7..b0327c0a83 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -189,6 +189,6 @@ def test_open_http_file(): res = open_http_file(conn_id="mock_conn_id", path="http://mock-path/to/docs") - mock_module.HttpHook.assert_called_once_with(http_conn_id="mock_conn_id") + mock_module.HttpHook.assert_called_once_with(method="GET", http_conn_id="mock_conn_id") mock_hook.get.assert_called_once_with(endpoint=path) assert res == "mock file contents" From 267fd8925a5bc198b11cd86240e7aacea1240eb9 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 6 Dec 2023 09:17:52 -0500 Subject: [PATCH 06/13] Update test_plugin.py --- tests/plugin/test_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index b0327c0a83..f9bc0d233d 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -190,5 +190,5 @@ def test_open_http_file(): res = open_http_file(conn_id="mock_conn_id", path="http://mock-path/to/docs") mock_module.HttpHook.assert_called_once_with(method="GET", http_conn_id="mock_conn_id") - mock_hook.get.assert_called_once_with(endpoint=path) + mock_hook.get.assert_called_once_with(endpoint="http://mock-path/to/docs") assert res == "mock file contents" From 6be6935c6ec9bb222f0f78072bf94100c13fe6cd Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 6 Dec 2023 09:27:18 -0500 Subject: [PATCH 07/13] Update test_plugin.py --- tests/plugin/test_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index f9bc0d233d..62e5a7ca96 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -190,5 +190,5 @@ def test_open_http_file(): res = open_http_file(conn_id="mock_conn_id", path="http://mock-path/to/docs") mock_module.HttpHook.assert_called_once_with(method="GET", http_conn_id="mock_conn_id") - mock_hook.get.assert_called_once_with(endpoint="http://mock-path/to/docs") + mock_hook.run.assert_called_once_with(endpoint="http://mock-path/to/docs") assert res == "mock file contents" From b8a886e2b7229740204aaf6b139175915e594b08 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 6 Dec 2023 11:22:53 -0500 Subject: [PATCH 08/13] Update test_plugin.py --- tests/plugin/test_plugin.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 62e5a7ca96..f571f8c526 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -13,7 +13,7 @@ jinja2.Markup = markupsafe.Markup jinja2.escape = markupsafe.escape -from unittest.mock import patch, MagicMock +from unittest.mock import mock_open, patch, MagicMock import sys import pytest @@ -192,3 +192,10 @@ def test_open_http_file(): mock_module.HttpHook.assert_called_once_with(method="GET", http_conn_id="mock_conn_id") mock_hook.run.assert_called_once_with(endpoint="http://mock-path/to/docs") assert res == "mock file contents" + + +@patch("builtins.open", mock_open(read_data="mock file contents")) +def test_open_file_local(mock_file): + res = open_file("/my/path") + mock_file.assert_called_with("/my/path") + assert res == "mock file contents" From daef1c821c50c67bc85bc57c55d6fab47ebd6ffa Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 6 Dec 2023 11:26:25 -0500 Subject: [PATCH 09/13] Update test_plugin.py --- tests/plugin/test_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index f571f8c526..5bdb81551e 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -194,7 +194,7 @@ def test_open_http_file(): assert res == "mock file contents" -@patch("builtins.open", mock_open(read_data="mock file contents")) +@patch("builtins.open", new_callable=mock_open, read_data="mock file contents") def test_open_file_local(mock_file): res = open_file("/my/path") mock_file.assert_called_with("/my/path") From 02911cf2e71afe49f9cea3baa6b1568eec290ea9 Mon Sep 17 00:00:00 2001 From: dwreeves Date: Thu, 7 Dec 2023 21:29:39 -0500 Subject: [PATCH 10/13] update tests --- cosmos/plugin/__init__.py | 11 ++---- tests/plugin/test_plugin.py | 72 ++++++++++++++++++++++++++----------- 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py index be5a5caacf..daf2863203 100644 --- a/cosmos/plugin/__init__.py +++ b/cosmos/plugin/__init__.py @@ -60,14 +60,9 @@ def open_http_file(conn_id: Optional[str], path: str) -> str: from airflow.providers.http.hooks.http import HttpHook if conn_id is None: - try: - HttpHook.get_connection(conn_id=HttpHook.default_conn_name) - except AirflowNotFoundException: - hook = HttpHook(method="GET", http_conn_id="") - else: - hook = HttpHook(method="GET") - else: - hook = HttpHook(method="GET", http_conn_id=conn_id) + 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] diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 5bdb81551e..d08cec5eae 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -13,7 +13,7 @@ jinja2.Markup = markupsafe.Markup jinja2.escape = markupsafe.escape -from unittest.mock import mock_open, patch, MagicMock +from unittest.mock import mock_open, patch, MagicMock, PropertyMock import sys import pytest @@ -40,6 +40,14 @@ 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() @@ -58,17 +66,27 @@ def app() -> FlaskClient: resetdb(skip_init=True) -@patch.object(cosmos.plugin, "open_file") -def test_dbt_docs(mock_open_file, monkeypatch, app): +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 " Date: Sat, 6 Jan 2024 14:17:33 -0500 Subject: [PATCH 11/13] updates --- cosmos/plugin/__init__.py | 31 ++++++++++------------------- docs/configuration/hosting-docs.rst | 18 ++++++++++++++++- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py index daf2863203..48061b254b 100644 --- a/cosmos/plugin/__init__.py +++ b/cosmos/plugin/__init__.py @@ -3,7 +3,6 @@ from urllib.parse import urlsplit from airflow.configuration import conf -from airflow.exceptions import AirflowConfigException, AirflowNotFoundException from airflow.plugins_manager import AirflowPlugin from airflow.security import permissions from airflow.www.auth import has_access @@ -70,10 +69,7 @@ def open_http_file(conn_id: Optional[str], path: str) -> str: def open_file(path: str) -> str: """Retrieve a file from http, https, gs, s3, or wasb.""" - try: - conn_id: Optional[str] = conf.get("cosmos", "dbt_docs_conn_id") - except AirflowConfigException: - conn_id = None + 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) @@ -163,20 +159,17 @@ def create_blueprint( @expose("/dbt_docs") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) def dbt_docs(self) -> str: - try: - conf.get("cosmos", "dbt_docs_dir") - except AirflowConfigException: + 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: - try: - docs_dir = conf.get("cosmos", "dbt_docs_dir") - html = open_file(op.join(docs_dir, "index.html")) - except (FileNotFoundError, AirflowConfigException): + 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) @@ -185,21 +178,19 @@ def dbt_docs_index(self) -> str: @expose("/catalog.json") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) def catalog(self) -> Tuple[str, int, Dict[str, Any]]: - try: - docs_dir = conf.get("cosmos", "dbt_docs_dir") - data = open_file(op.join(docs_dir, "catalog.json")) - except (FileNotFoundError, AirflowConfigException): + 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]]: - try: - docs_dir = conf.get("cosmos", "dbt_docs_dir") - data = open_file(op.join(docs_dir, "manifest.json")) - except (FileNotFoundError, AirflowConfigException): + 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"} diff --git a/docs/configuration/hosting-docs.rst b/docs/configuration/hosting-docs.rst index 698af510a6..862c2487c0 100644 --- a/docs/configuration/hosting-docs.rst +++ b/docs/configuration/hosting-docs.rst @@ -88,7 +88,7 @@ Azure Blob Storage Example 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. (For example, you are setting the +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. @@ -109,3 +109,19 @@ Using docs out of local storage has the downside that some values in the dbt doc - 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. From c67f05da1322b906d2dc789601c5cbc821304d3c Mon Sep 17 00:00:00 2001 From: dwreeves Date: Sat, 6 Jan 2024 20:34:50 -0500 Subject: [PATCH 12/13] fix test --- tests/plugin/test_plugin.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index d08cec5eae..df33ae13ac 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -114,15 +114,7 @@ def conf_get(section, key, *args, **kwargs): @pytest.mark.parametrize("artifact", ["dbt_docs_index.html", "manifest.json", "catalog.json"]) -def test_dbt_docs_artifact_missing(app, artifact, monkeypatch): - def conf_get(section, key, *args, **kwargs): - if section == "cosmos": - raise AirflowConfigException - else: - return original_conf_get(section, key, *args, **kwargs) - - monkeypatch.setattr(conf, "get", conf_get) - +def test_dbt_docs_artifact_missing(app, artifact): response = app.get(f"/cosmos/{artifact}") assert response.status_code == 404 From a2395b058cfa37d22035214618d8fb42a93ce463 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Tue, 20 Feb 2024 12:49:19 -0500 Subject: [PATCH 13/13] Update hosting-docs.rst Co-authored-by: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> --- docs/configuration/hosting-docs.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/hosting-docs.rst b/docs/configuration/hosting-docs.rst index 862c2487c0..5143a9f67f 100644 --- a/docs/configuration/hosting-docs.rst +++ b/docs/configuration/hosting-docs.rst @@ -68,7 +68,7 @@ Google Cloud Storage Example .. code-block:: shell - AIRFLOW__COSMOS__DBT_DOCS_DIR="s3://my-bucket/path/to/docs" + AIRFLOW__COSMOS__DBT_DOCS_DIR="gs://my-bucket/path/to/docs" AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="google_cloud_default" Azure Blob Storage Example