diff --git a/src/datapilot/cli/decorators.py b/src/datapilot/cli/decorators.py new file mode 100644 index 0000000..6267f4c --- /dev/null +++ b/src/datapilot/cli/decorators.py @@ -0,0 +1,88 @@ +import json +import os +import re +from functools import wraps +from pathlib import Path + +import click +from dotenv import load_dotenv + + +def load_config_from_file(): + """Load configuration from ~/.altimate/altimate.json if it exists.""" + config_path = Path.home() / ".altimate" / "altimate.json" + + if not config_path.exists(): + return {} + + try: + with config_path.open() as f: + config = json.load(f) + return config + except (OSError, json.JSONDecodeError) as e: + click.echo(f"Warning: Failed to load config from {config_path}: {e}", err=True) + return {} + + +def substitute_env_vars(value): + """Replace ${env:ENV_VARIABLE} patterns with actual environment variable values.""" + if not isinstance(value, str): + return value + + # Pattern to match ${env:VARIABLE_NAME} + pattern = r"\$\{env:([^}]+)\}" + + def replacer(match): + env_var = match.group(1) + return os.environ.get(env_var, match.group(0)) + + return re.sub(pattern, replacer, value) + + +def process_config(config): + """Process configuration dictionary to substitute environment variables.""" + processed = {} + for key, value in config.items(): + processed[key] = substitute_env_vars(value) + return processed + + +def auth_options(f): + """Decorator to add authentication options to commands.""" + + @click.option("--token", required=False, help="Your API token for authentication.", hide_input=True) + @click.option("--instance-name", required=False, help="Your tenant ID.") + @click.option("--backend-url", required=False, help="Altimate's Backend URL", default="https://api.myaltimate.com") + @wraps(f) + def wrapper(token, instance_name, backend_url, *args, **kwargs): + # Load .env file from current directory if it exists + load_dotenv() + + # Load configuration from file + file_config = load_config_from_file() + file_config = process_config(file_config) + + # Apply file config first, then override with CLI arguments if provided + final_token = token + final_instance_name = instance_name + final_backend_url = backend_url + + # Use file config if CLI argument not provided + if final_token is None and "altimateApiKey" in file_config: + final_token = file_config["altimateApiKey"] + if final_instance_name is None and "altimateInstanceName" in file_config: + final_instance_name = file_config["altimateInstanceName"] + if final_backend_url == "https://api.myaltimate.com" and "altimateUrl" in file_config: + final_backend_url = file_config["altimateUrl"] + + # Set defaults if nothing was provided + if final_token is None: + final_token = None + if final_instance_name is None: + final_instance_name = None + if final_backend_url is None: + final_backend_url = "https://api.myaltimate.com" + + return f(final_token, final_instance_name, final_backend_url, *args, **kwargs) + + return wrapper diff --git a/src/datapilot/cli/main.py b/src/datapilot/cli/main.py index 87a6135..dcf0a92 100644 --- a/src/datapilot/cli/main.py +++ b/src/datapilot/cli/main.py @@ -1,10 +1,4 @@ -import json -import os -import re -from pathlib import Path - import click -from dotenv import load_dotenv from datapilot import __version__ from datapilot.core.knowledge.cli import cli as knowledge @@ -12,83 +6,10 @@ from datapilot.core.platforms.dbt.cli.cli import dbt -def load_config_from_file(): - """Load configuration from ~/.altimate/altimate.json if it exists.""" - config_path = Path.home() / ".altimate" / "altimate.json" - - if not config_path.exists(): - return {} - - try: - with config_path.open() as f: - config = json.load(f) - return config - except (OSError, json.JSONDecodeError) as e: - click.echo(f"Warning: Failed to load config from {config_path}: {e}", err=True) - return {} - - -def substitute_env_vars(value): - """Replace ${env:ENV_VARIABLE} patterns with actual environment variable values.""" - if not isinstance(value, str): - return value - - # Pattern to match ${env:VARIABLE_NAME} - pattern = r"\$\{env:([^}]+)\}" - - def replacer(match): - env_var = match.group(1) - return os.environ.get(env_var, match.group(0)) - - return re.sub(pattern, replacer, value) - - -def process_config(config): - """Process configuration dictionary to substitute environment variables.""" - processed = {} - for key, value in config.items(): - processed[key] = substitute_env_vars(value) - return processed - - @click.group() @click.version_option(version=__version__, prog_name="datapilot") -@click.option("--token", required=False, help="Your API token for authentication.", hide_input=True) -@click.option("--instance-name", required=False, help="Your tenant ID.") -@click.option("--backend-url", required=False, help="Altimate's Backend URL", default="https://api.myaltimate.com") -@click.pass_context -def datapilot(ctx, token, instance_name, backend_url): +def datapilot(): """Altimate CLI for DBT project management.""" - # Load .env file from current directory if it exists - load_dotenv() - - # Load configuration from file - file_config = load_config_from_file() - file_config = process_config(file_config) - - # Map config file keys to CLI option names - config_mapping = {"altimateApiKey": "token", "altimateInstanceName": "instance_name", "altimateUrl": "backend_url"} - - # Store common options in context, with CLI args taking precedence - ctx.ensure_object(dict) - - # Apply file config first - for file_key, cli_key in config_mapping.items(): - if file_key in file_config: - ctx.obj[cli_key] = file_config[file_key] - - # Override with CLI arguments if provided - if token is not None: - ctx.obj["token"] = token - if instance_name is not None: - ctx.obj["instance_name"] = instance_name - if backend_url != "https://api.myaltimate.com": # Only override if not default - ctx.obj["backend_url"] = backend_url - - # Set defaults if nothing was provided - ctx.obj.setdefault("token", None) - ctx.obj.setdefault("instance_name", None) - ctx.obj.setdefault("backend_url", "https://api.myaltimate.com") datapilot.add_command(dbt) diff --git a/src/datapilot/core/knowledge/cli.py b/src/datapilot/core/knowledge/cli.py index 2fe30f8..3119a21 100644 --- a/src/datapilot/core/knowledge/cli.py +++ b/src/datapilot/core/knowledge/cli.py @@ -2,6 +2,8 @@ import click +from datapilot.cli.decorators import auth_options + from .server import KnowledgeBaseHandler @@ -11,20 +13,15 @@ def cli(): @cli.command() +@auth_options @click.option("--port", default=4000, help="Port to run the server on") -@click.pass_context -def serve(ctx, port): +def serve(token, instance_name, backend_url, port): """Serve knowledge bases via HTTP server.""" - # Get configuration from parent context - token = ctx.parent.obj.get("token") - instance_name = ctx.parent.obj.get("instance_name") - backend_url = ctx.parent.obj.get("backend_url") - if not token or not instance_name: click.echo( "Error: API token and instance name are required. Use --token and --instance-name options or set them in config.", err=True ) - ctx.exit(1) + raise click.Abort # Set context data for the handler KnowledgeBaseHandler.token = token diff --git a/src/datapilot/core/platforms/dbt/cli/cli.py b/src/datapilot/core/platforms/dbt/cli/cli.py index 0f63167..c7289c9 100644 --- a/src/datapilot/core/platforms/dbt/cli/cli.py +++ b/src/datapilot/core/platforms/dbt/cli/cli.py @@ -2,6 +2,7 @@ import click +from datapilot.cli.decorators import auth_options from datapilot.clients.altimate.utils import check_token_and_instance from datapilot.clients.altimate.utils import get_all_dbt_configs from datapilot.clients.altimate.utils import onboard_file @@ -24,14 +25,12 @@ # New dbt group @click.group() -@click.pass_context -def dbt(ctx): +def dbt(): """DBT specific commands.""" - # Ensure context object exists - ctx.ensure_object(dict) @dbt.command("project-health") +@auth_options @click.option( "--manifest-path", required=True, @@ -58,9 +57,10 @@ def dbt(ctx): default=None, help="Selective model testing. Specify one or more models to run tests on.", ) -@click.pass_context def project_health( - ctx, + token, + instance_name, + backend_url, manifest_path, catalog_path, config_path=None, @@ -71,10 +71,6 @@ def project_health( Validate the DBT project's configuration and structure. :param manifest_path: Path to the DBT manifest file. """ - # Get common options from parent context - token = ctx.parent.obj.get("token") - instance_name = ctx.parent.obj.get("instance_name") - backend_url = ctx.parent.obj.get("backend_url") config = None if config_path: @@ -135,25 +131,23 @@ def project_health( @dbt.command("onboard") +@auth_options @click.option("--dbt_core_integration_id", prompt="DBT Core Integration ID", help="DBT Core Integration ID") @click.option( "--dbt_core_integration_environment", default="PROD", prompt="DBT Core Integration Environment", help="DBT Core Integration Environment" ) @click.option("--manifest-path", required=True, prompt="Manifest Path", help="Path to the manifest file.") @click.option("--catalog-path", required=False, prompt=False, help="Path to the catalog file.") -@click.pass_context def onboard( - ctx, + token, + instance_name, + backend_url, dbt_core_integration_id, dbt_core_integration_environment, manifest_path, catalog_path, ): """Onboard a manifest file to DBT.""" - # Get common options from parent context - token = ctx.parent.obj.get("token") - instance_name = ctx.parent.obj.get("instance_name") - backend_url = ctx.parent.obj.get("backend_url") # For onboard command, token and instance_name are required if not token: