Repository: gouline/dbt-metabase (language: python)

Issue:
feat: Support --metabase_session_id
Metabase lacks proper support for authenticating from automation, which
means we have to exchange a username and password for a session token
before each invocation of this command.

These count as login attempts, which are rate limited. Depending on your
needs, you may hit the rate limit and find your schemas can no longer be
synced.

Providing a --metabase_session_id allows the user to reuse and existing
Metabase session, should they have it, allowing them to avoid the login
rate limits (and in some cases, an annoying email!).


Discussion / hints:
Hey team! I haven't written any Python in years so this is unlikely to work, but I'd love early feedback on the proposed change.

I'll make sure this is actually functional once it gets the green light.
Approach looks fine overall, you have some syntax errors as the CI shows. Please run these checks locally to debug and let me know when you're ready for a code review. 
Yep, I definitely eye balled this! Now I know you're ok with the approach I'll fix it up on Monday :)
@lawrencejones Still planning to get this merged?
Ah sorry about this, haven't had a chance to get back to our data stuff since I was first working on it.

Appreciate you getting this over the line 🙌 

Fix patch:
diff --git a/README.md b/README.md
index 0c1ae5f2..63be135e 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,15 @@ Once `dbt compile` finishes, `manifest.json` can be found in the `target/` direc
 
 See [dbt documentation](https://docs.getdbt.com/docs/running-a-dbt-project/run-your-dbt-projects) for more information.
 
+## Metabase API
+
+All commands require authentication against the [Metabase API](https://www.metabase.com/docs/latest/api-documentation) using one of these methods:
+
+* API key (`--metabase-api-key`) 
+  - Strongly **recommended** for automation, see [documentation](https://www.metabase.com/docs/latest/people-and-groups/api-keys) (Metabase 49 or later).
+* Username and password (`--metabase-username` / `--metabase-password`)
+  - Fallback for older versions of Metabase and smaller instances.
+
 ## Exporting Models
 
 Let's start by defining a short sample `schema.yml` as below.
@@ -81,8 +90,7 @@ This is already enough to propagate the primary keys, foreign keys and descripti
 dbt-metabase models \
     --manifest-path target/manifest.json \
     --metabase-url https://metabase.example.com \
-    --metabase-username user@example.com \
-    --metabase-password Password123 \
+    --metabase-api-key mb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX= \
     --metabase-database business \
     --include-schemas public
 ```
@@ -208,8 +216,7 @@ dbt-metabase allows you to extract questions and dashboards from Metabase as [db
 dbt-metabase exposures \
     --manifest-path ./target/manifest.json \
     --metabase-url https://metabase.example.com \
-    --metabase-username user@example.com \
-    --metabase-password Password123 \
+    --metabase-api-key mb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX= \
     --output-path models/ \
     --exclude-collections "temp*"
 ```
@@ -259,8 +266,7 @@ A configuration file can be created in `~/.dbt-metabase/config.yml` for dbt-meta
 config:
     manifest_path: target/manifest.json
     metabase_url: https://metabase.example.com
-    metabase_username: user@example.com
-    metabase_password: Password123
+    metabase_api_key: mb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=
     # Configuration specific to models command
     models:
       metabase_database: business
@@ -282,8 +288,7 @@ from dbtmetabase import DbtMetabase, Filter
 c = DbtMetabase(
     manifest_path="target/manifest.json",
     metabase_url="https://metabase.example.com",
-    metabase_username="user@example.com",
-    metabase_password="Password123",
+    metabase_api_key="mb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=",
 )
 
 # Exporting models
diff --git a/dbtmetabase/__main__.py b/dbtmetabase/__main__.py
index 779c5c5f..d8771b68 100644
--- a/dbtmetabase/__main__.py
+++ b/dbtmetabase/__main__.py
@@ -95,13 +95,21 @@ def _add_setup(func: Callable) -> Callable:
         type=click.STRING,
         help="Metabase URL, e.g. 'https://metabase.example.com'.",
     )
+    @click.option(
+        "--metabase-api-key",
+        metavar="API_KEY",
+        envvar="METABASE_API_KEY",
+        show_envvar=True,
+        type=click.STRING,
+        help="Metabase API key (required unless providing username/password).",
+    )
     @click.option(
         "--metabase-username",
         metavar="USERNAME",
         envvar="METABASE_USERNAME",
         show_envvar=True,
         type=click.STRING,
-        help="Metabase username (required unless providing session ID).",
+        help="Metabase username (required unless providing API key).",
     )
     @click.option(
         "--metabase-password",
@@ -109,7 +117,7 @@ def _add_setup(func: Callable) -> Callable:
         envvar="METABASE_PASSWORD",
         show_envvar=True,
         type=click.STRING,
-        help="Metabase password (required unless providing session ID).",
+        help="Metabase password (required unless providing API key).",
     )
     @click.option(
         "--metabase-session-id",
@@ -117,7 +125,8 @@ def _add_setup(func: Callable) -> Callable:
         envvar="METABASE_SESSION_ID",
         show_envvar=True,
         type=click.STRING,
-        help="Metabase session ID (alternative to username/password).",
+        help="Metabase session ID (deprecated and will be removed in future).",
+        hidden=True,
     )
     @click.option(
         "--skip-verify",
@@ -160,6 +169,7 @@ def _add_setup(func: Callable) -> Callable:
     def wrapper(
         manifest_path: str,
         metabase_url: str,
+        metabase_api_key: str,
         metabase_username: str,
         metabase_password: str,
         metabase_session_id: Optional[str],
@@ -179,6 +189,7 @@ def wrapper(
             core=DbtMetabase(
                 manifest_path=manifest_path,
                 metabase_url=metabase_url,
+                metabase_api_key=metabase_api_key,
                 metabase_username=metabase_username,
                 metabase_password=metabase_password,
                 metabase_session_id=metabase_session_id,
diff --git a/dbtmetabase/core.py b/dbtmetabase/core.py
index 7812ce8b..3aee5a74 100644
--- a/dbtmetabase/core.py
+++ b/dbtmetabase/core.py
@@ -23,6 +23,7 @@ def __init__(
         self,
         manifest_path: Union[str, Path],
         metabase_url: str,
+        metabase_api_key: Optional[str] = None,
         metabase_username: Optional[str] = None,
         metabase_password: Optional[str] = None,
         metabase_session_id: Optional[str] = None,
@@ -37,9 +38,10 @@ def __init__(
         Args:
             manifest_path (Union[str,Path]): Path to dbt manifest.json, usually in target/ directory after compilation.
             metabase_url (str): Metabase URL, e.g. "https://metabase.example.com".
-            metabase_username (Optional[str], optional): Metabase username (required unless providing session ID). Defaults to None.
-            metabase_password (Optional[str], optional): Metabase password (required unless providing session ID). Defaults to None.
-            metabase_session_id (Optional[str], optional): Metabase session ID. Defaults to None.
+            metabase_api_key (Optional[str], optional): Metabase API key (required unless providing username/password or session ID). Defaults to None.
+            metabase_username (Optional[str], optional): Metabase username (required unless providing API key or session ID). Defaults to None.
+            metabase_password (Optional[str], optional): Metabase password (required unless providing API key or session ID). Defaults to None.
+            metabase_session_id (Optional[str], optional): Metabase session ID (deprecated and will be removed in future). Defaults to None.
             skip_verify (bool, optional): Skip TLS certificate verification (not recommended). Defaults to False.
             cert (Optional[Union[str, Tuple[str, str]]], optional): Path to a custom certificate. Defaults to None.
             http_timeout (int, optional): HTTP request timeout in secs. Defaults to 15.
@@ -52,6 +54,7 @@ def __init__(
         )
         self._metabase = Metabase(
             url=metabase_url,
+            api_key=metabase_api_key,
             username=metabase_username,
             password=metabase_password,
             session_id=metabase_session_id,
diff --git a/dbtmetabase/metabase.py b/dbtmetabase/metabase.py
index 5e66de95..8410d93c 100644
--- a/dbtmetabase/metabase.py
+++ b/dbtmetabase/metabase.py
@@ -13,6 +13,7 @@ class Metabase:
     def __init__(
         self,
         url: str,
+        api_key: Optional[str],
         username: Optional[str],
         password: Optional[str],
         session_id: Optional[str],
@@ -38,19 +39,24 @@ def __init__(
             http_adapter or HTTPAdapter(max_retries=Retry(total=3, backoff_factor=1)),
         )
 
-        if not session_id:
-            if username and password:
-                session = dict(
-                    self._api(
-                        method="post",
-                        path="/api/session",
-                        json={"username": username, "password": password},
-                    )
+        if api_key:
+            self.session.headers["X-API-KEY"] = api_key
+        elif username and password:
+            session = dict(
+                self._api(
+                    method="post",
+                    path="/api/session",
+                    json={"username": username, "password": password},
                 )
-                session_id = str(session["id"])
-            else:
-                raise ArgumentError("Metabase credentials or session ID required")
-        self.session.headers["X-Metabase-Session"] = session_id
+            )
+            self.session.headers["X-Metabase-Session"] = str(session["id"])
+        elif session_id:
+            _logger.warning(
+                "Metabase session ID is deprecated and will be removed in future, use API key or username/password instead"
+            )
+            self.session.headers["X-Metabase-Session"] = session_id
+        else:
+            raise ArgumentError("Metabase API key or username/password required")
 
         _logger.info("Metabase session established")
 


Test patch:
diff --git a/tests/_mocks.py b/tests/_mocks.py
index a2227021..246f463b 100644
--- a/tests/_mocks.py
+++ b/tests/_mocks.py
@@ -16,6 +16,7 @@ class MockMetabase(Metabase):
     def __init__(self, url: str):
         super().__init__(
             url=url,
+            api_key=None,
             username=None,
             password=None,
             session_id="dummy",