Skip to content

Commit 16c1191

Browse files
committed
refactor: add auth_options decorator for CLI commands
1 parent 5f679ab commit 16c1191

File tree

4 files changed

+111
-94
lines changed

4 files changed

+111
-94
lines changed

src/datapilot/cli/decorators.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import json
2+
import os
3+
import re
4+
from functools import wraps
5+
from pathlib import Path
6+
7+
import click
8+
from dotenv import load_dotenv
9+
10+
11+
def load_config_from_file():
12+
"""Load configuration from ~/.altimate/altimate.json if it exists."""
13+
config_path = Path.home() / ".altimate" / "altimate.json"
14+
15+
if not config_path.exists():
16+
return {}
17+
18+
try:
19+
with config_path.open() as f:
20+
config = json.load(f)
21+
return config
22+
except (OSError, json.JSONDecodeError) as e:
23+
click.echo(f"Warning: Failed to load config from {config_path}: {e}", err=True)
24+
return {}
25+
26+
27+
def substitute_env_vars(value):
28+
"""Replace ${env:ENV_VARIABLE} patterns with actual environment variable values."""
29+
if not isinstance(value, str):
30+
return value
31+
32+
# Pattern to match ${env:VARIABLE_NAME}
33+
pattern = r"\$\{env:([^}]+)\}"
34+
35+
def replacer(match):
36+
env_var = match.group(1)
37+
return os.environ.get(env_var, match.group(0))
38+
39+
return re.sub(pattern, replacer, value)
40+
41+
42+
def process_config(config):
43+
"""Process configuration dictionary to substitute environment variables."""
44+
processed = {}
45+
for key, value in config.items():
46+
processed[key] = substitute_env_vars(value)
47+
return processed
48+
49+
50+
def auth_options(f):
51+
"""Decorator to add authentication options to commands."""
52+
@click.option("--token", required=False, help="Your API token for authentication.", hide_input=True)
53+
@click.option("--instance-name", required=False, help="Your tenant ID.")
54+
@click.option("--backend-url", required=False, help="Altimate's Backend URL", default="https://api.myaltimate.com")
55+
@wraps(f)
56+
def wrapper(ctx, token, instance_name, backend_url, *args, **kwargs):
57+
# Load .env file from current directory if it exists
58+
load_dotenv()
59+
60+
# Load configuration from file
61+
file_config = load_config_from_file()
62+
file_config = process_config(file_config)
63+
64+
# Map config file keys to CLI option names
65+
config_mapping = {"altimateApiKey": "token", "altimateInstanceName": "instance_name", "altimateUrl": "backend_url"}
66+
67+
# Store common options in context
68+
ctx.ensure_object(dict)
69+
70+
# Apply file config first
71+
for file_key, cli_key in config_mapping.items():
72+
if file_key in file_config:
73+
ctx.obj[cli_key] = file_config[file_key]
74+
75+
# Override with CLI arguments if provided
76+
if token is not None:
77+
ctx.obj["token"] = token
78+
if instance_name is not None:
79+
ctx.obj["instance_name"] = instance_name
80+
if backend_url != "https://api.myaltimate.com": # Only override if not default
81+
ctx.obj["backend_url"] = backend_url
82+
83+
# Set defaults if nothing was provided
84+
ctx.obj.setdefault("token", None)
85+
ctx.obj.setdefault("instance_name", None)
86+
ctx.obj.setdefault("backend_url", "https://api.myaltimate.com")
87+
88+
return f(ctx, *args, **kwargs)
89+
90+
return wrapper

src/datapilot/cli/main.py

Lines changed: 2 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,19 @@
1-
import json
2-
import os
3-
import re
4-
from pathlib import Path
5-
61
import click
7-
from dotenv import load_dotenv
82

93
from datapilot import __version__
104
from datapilot.core.knowledge.cli import cli as knowledge
115
from datapilot.core.mcp_utils.mcp import mcp
126
from datapilot.core.platforms.dbt.cli.cli import dbt
137

148

15-
def load_config_from_file():
16-
"""Load configuration from ~/.altimate/altimate.json if it exists."""
17-
config_path = Path.home() / ".altimate" / "altimate.json"
18-
19-
if not config_path.exists():
20-
return {}
21-
22-
try:
23-
with config_path.open() as f:
24-
config = json.load(f)
25-
return config
26-
except (OSError, json.JSONDecodeError) as e:
27-
click.echo(f"Warning: Failed to load config from {config_path}: {e}", err=True)
28-
return {}
29-
30-
31-
def substitute_env_vars(value):
32-
"""Replace ${env:ENV_VARIABLE} patterns with actual environment variable values."""
33-
if not isinstance(value, str):
34-
return value
35-
36-
# Pattern to match ${env:VARIABLE_NAME}
37-
pattern = r"\$\{env:([^}]+)\}"
38-
39-
def replacer(match):
40-
env_var = match.group(1)
41-
return os.environ.get(env_var, match.group(0))
42-
43-
return re.sub(pattern, replacer, value)
44-
45-
46-
def process_config(config):
47-
"""Process configuration dictionary to substitute environment variables."""
48-
processed = {}
49-
for key, value in config.items():
50-
processed[key] = substitute_env_vars(value)
51-
return processed
52-
53-
549
@click.group()
5510
@click.version_option(version=__version__, prog_name="datapilot")
56-
@click.option("--token", required=False, help="Your API token for authentication.", hide_input=True)
57-
@click.option("--instance-name", required=False, help="Your tenant ID.")
58-
@click.option("--backend-url", required=False, help="Altimate's Backend URL", default="https://api.myaltimate.com")
5911
@click.pass_context
60-
def datapilot(ctx, token, instance_name, backend_url):
12+
def datapilot(ctx):
6113
"""Altimate CLI for DBT project management."""
62-
# Load .env file from current directory if it exists
63-
load_dotenv()
64-
65-
# Load configuration from file
66-
file_config = load_config_from_file()
67-
file_config = process_config(file_config)
68-
69-
# Map config file keys to CLI option names
70-
config_mapping = {"altimateApiKey": "token", "altimateInstanceName": "instance_name", "altimateUrl": "backend_url"}
71-
72-
# Store common options in context, with CLI args taking precedence
14+
# Ensure context object exists
7315
ctx.ensure_object(dict)
7416

75-
# Apply file config first
76-
for file_key, cli_key in config_mapping.items():
77-
if file_key in file_config:
78-
ctx.obj[cli_key] = file_config[file_key]
79-
80-
# Override with CLI arguments if provided
81-
if token is not None:
82-
ctx.obj["token"] = token
83-
if instance_name is not None:
84-
ctx.obj["instance_name"] = instance_name
85-
if backend_url != "https://api.myaltimate.com": # Only override if not default
86-
ctx.obj["backend_url"] = backend_url
87-
88-
# Set defaults if nothing was provided
89-
ctx.obj.setdefault("token", None)
90-
ctx.obj.setdefault("instance_name", None)
91-
ctx.obj.setdefault("backend_url", "https://api.myaltimate.com")
92-
9317

9418
datapilot.add_command(dbt)
9519
datapilot.add_command(mcp)

src/datapilot/core/knowledge/cli.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import click
44

5+
from datapilot.cli.decorators import auth_options
6+
57
from .server import KnowledgeBaseHandler
68

79

@@ -11,14 +13,15 @@ def cli():
1113

1214

1315
@cli.command()
16+
@auth_options
1417
@click.option("--port", default=4000, help="Port to run the server on")
1518
@click.pass_context
1619
def serve(ctx, port):
1720
"""Serve knowledge bases via HTTP server."""
18-
# Get configuration from parent context
19-
token = ctx.parent.obj.get("token")
20-
instance_name = ctx.parent.obj.get("instance_name")
21-
backend_url = ctx.parent.obj.get("backend_url")
21+
# Get configuration from context
22+
token = ctx.obj.get("token")
23+
instance_name = ctx.obj.get("instance_name")
24+
backend_url = ctx.obj.get("backend_url")
2225

2326
if not token or not instance_name:
2427
click.echo(

src/datapilot/core/platforms/dbt/cli/cli.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import click
44

5+
from datapilot.cli.decorators import auth_options
56
from datapilot.clients.altimate.utils import check_token_and_instance
67
from datapilot.clients.altimate.utils import get_all_dbt_configs
78
from datapilot.clients.altimate.utils import onboard_file
@@ -24,14 +25,12 @@
2425

2526
# New dbt group
2627
@click.group()
27-
@click.pass_context
28-
def dbt(ctx):
28+
def dbt():
2929
"""DBT specific commands."""
30-
# Ensure context object exists
31-
ctx.ensure_object(dict)
3230

3331

3432
@dbt.command("project-health")
33+
@auth_options
3534
@click.option(
3635
"--manifest-path",
3736
required=True,
@@ -71,10 +70,10 @@ def project_health(
7170
Validate the DBT project's configuration and structure.
7271
:param manifest_path: Path to the DBT manifest file.
7372
"""
74-
# Get common options from parent context
75-
token = ctx.parent.obj.get("token")
76-
instance_name = ctx.parent.obj.get("instance_name")
77-
backend_url = ctx.parent.obj.get("backend_url")
73+
# Get common options from context
74+
token = ctx.obj.get("token")
75+
instance_name = ctx.obj.get("instance_name")
76+
backend_url = ctx.obj.get("backend_url")
7877

7978
config = None
8079
if config_path:
@@ -135,6 +134,7 @@ def project_health(
135134

136135

137136
@dbt.command("onboard")
137+
@auth_options
138138
@click.option("--dbt_core_integration_id", prompt="DBT Core Integration ID", help="DBT Core Integration ID")
139139
@click.option(
140140
"--dbt_core_integration_environment", default="PROD", prompt="DBT Core Integration Environment", help="DBT Core Integration Environment"
@@ -150,10 +150,10 @@ def onboard(
150150
catalog_path,
151151
):
152152
"""Onboard a manifest file to DBT."""
153-
# Get common options from parent context
154-
token = ctx.parent.obj.get("token")
155-
instance_name = ctx.parent.obj.get("instance_name")
156-
backend_url = ctx.parent.obj.get("backend_url")
153+
# Get common options from context
154+
token = ctx.obj.get("token")
155+
instance_name = ctx.obj.get("instance_name")
156+
backend_url = ctx.obj.get("backend_url")
157157

158158
# For onboard command, token and instance_name are required
159159
if not token:

0 commit comments

Comments
 (0)