Skip to content

Commit c636ad5

Browse files
committed
fix: merge
2 parents b08db32 + 5f679ab commit c636ad5

File tree

10 files changed

+265
-27
lines changed

10 files changed

+265
-27
lines changed

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.0.19
2+
current_version = 0.0.20
33
commit = True
44
tag = True
55

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
year = "2024"
1616
author = "Altimate Inc."
1717
copyright = f"{year}, {author}"
18-
version = release = "0.0.19"
18+
version = release = "0.0.20"
1919

2020
pygments_style = "trac"
2121
templates_path = ["."]

setup.py

100644100755
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def read(*names, **kwargs):
1313

1414
setup(
1515
name="altimate-datapilot-cli",
16-
version="0.0.19",
16+
version="0.0.20",
1717
license="MIT",
1818
description="Assistant for Data Teams",
1919
long_description="{}\n{}".format(
@@ -70,6 +70,7 @@ def read(*names, **kwargs):
7070
"sqlglot~=25.30.0",
7171
"mcp~=1.9.0",
7272
"pyperclip~=1.8.2",
73+
"python-dotenv~=1.0.0",
7374
],
7475
extras_require={
7576
# eg:

src/datapilot/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.0.19"
1+
__version__ = "0.0.20"

src/datapilot/cli/main.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,96 @@
1+
import json
2+
import os
3+
import re
4+
from pathlib import Path
5+
16
import click
7+
from dotenv import load_dotenv
28

9+
from datapilot import __version__
10+
from datapilot.core.knowledge.cli import cli as knowledge
311
from datapilot.core.mcp_utils.mcp import mcp
412
from datapilot.core.platforms.dbt.cli.cli import dbt
513

614

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+
754
@click.group()
8-
def datapilot():
55+
@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")
59+
@click.pass_context
60+
def datapilot(ctx, token, instance_name, backend_url):
961
"""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
73+
ctx.ensure_object(dict)
74+
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")
1092

1193

1294
datapilot.add_command(dbt)
1395
datapilot.add_command(mcp)
96+
datapilot.add_command(knowledge)

src/datapilot/core/knowledge/__init__.py

Whitespace-only changes.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from http.server import HTTPServer
2+
3+
import click
4+
5+
from .server import KnowledgeBaseHandler
6+
7+
8+
@click.group(name="knowledge")
9+
def cli():
10+
"""knowledge specific commands."""
11+
12+
13+
@cli.command()
14+
@click.option("--port", default=4000, help="Port to run the server on")
15+
@click.pass_context
16+
def serve(ctx, port):
17+
"""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")
22+
23+
if not token or not instance_name:
24+
click.echo(
25+
"Error: API token and instance name are required. Use --token and --instance-name options or set them in config.", err=True
26+
)
27+
ctx.exit(1)
28+
29+
# Set context data for the handler
30+
KnowledgeBaseHandler.token = token
31+
KnowledgeBaseHandler.instance_name = instance_name
32+
KnowledgeBaseHandler.backend_url = backend_url
33+
34+
server_address = ("", port)
35+
httpd = HTTPServer(server_address, KnowledgeBaseHandler)
36+
37+
click.echo(f"Starting knowledge base server on port {port}...")
38+
click.echo(f"Backend URL: {backend_url}")
39+
click.echo(f"Instance: {instance_name}")
40+
click.echo(f"Server running at http://localhost:{port}")
41+
42+
try:
43+
httpd.serve_forever()
44+
except KeyboardInterrupt:
45+
click.echo("\nShutting down server...")
46+
httpd.shutdown()
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import json
2+
import re
3+
from http.server import BaseHTTPRequestHandler
4+
from urllib.error import HTTPError
5+
from urllib.error import URLError
6+
from urllib.parse import urlparse
7+
from urllib.request import Request
8+
from urllib.request import urlopen
9+
10+
import click
11+
12+
13+
class KnowledgeBaseHandler(BaseHTTPRequestHandler):
14+
"""HTTP request handler for serving knowledge bases and health checks."""
15+
16+
token: str = ""
17+
instance_name: str = ""
18+
backend_url: str = ""
19+
20+
def do_GET(self):
21+
"""Handle GET requests."""
22+
path = urlparse(self.path).path
23+
24+
# Match /knowledge_bases/{uuid} pattern
25+
match = re.match(r"^/kb/([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$", path)
26+
27+
if match:
28+
public_id = match.group(1)
29+
self.handle_knowledge_base(public_id)
30+
elif path == "/health":
31+
self.handle_health()
32+
else:
33+
self.send_error(404, "Not Found")
34+
35+
def handle_knowledge_base(self, public_id):
36+
"""Fetch and return knowledge base data."""
37+
url = f"{self.backend_url}/knowledge_bases/private/{public_id}"
38+
39+
# Validate URL scheme for security
40+
parsed_url = urlparse(url)
41+
if parsed_url.scheme not in ("http", "https"):
42+
self.send_response(400)
43+
self.send_header("Content-Type", "application/json")
44+
self.end_headers()
45+
error_msg = json.dumps({"error": "Invalid URL scheme. Only HTTP and HTTPS are allowed."})
46+
self.wfile.write(error_msg.encode("utf-8"))
47+
return
48+
49+
headers = {"Authorization": f"Bearer {self.token}", "X-Tenant": self.instance_name, "Content-Type": "application/json"}
50+
51+
req = Request(url, headers=headers) # noqa: S310
52+
53+
try:
54+
# URL scheme validated above - only HTTP/HTTPS allowed
55+
with urlopen(req, timeout=30) as response: # noqa: S310
56+
data = response.read()
57+
self.send_response(200)
58+
self.send_header("Content-Type", "application/json")
59+
self.end_headers()
60+
self.wfile.write(data)
61+
except HTTPError as e:
62+
error_body = e.read()
63+
error_data = error_body.decode("utf-8") if error_body else '{"error": "HTTP Error"}'
64+
self.send_response(e.code)
65+
self.send_header("Content-Type", "application/json")
66+
self.end_headers()
67+
self.wfile.write(error_data.encode("utf-8"))
68+
except URLError as e:
69+
self.send_response(500)
70+
self.send_header("Content-Type", "application/json")
71+
self.end_headers()
72+
error_msg = json.dumps({"error": str(e)})
73+
self.wfile.write(error_msg.encode("utf-8"))
74+
75+
def handle_health(self):
76+
"""Handle health check endpoint."""
77+
self.send_response(200)
78+
self.send_header("Content-Type", "application/json")
79+
self.end_headers()
80+
self.wfile.write(json.dumps({"status": "ok"}).encode("utf-8"))
81+
82+
def log_message(self, format, *args):
83+
"""Override to use click.echo for logging."""
84+
click.echo(f"{self.address_string()} - {format % args}")

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

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@
2424

2525
# New dbt group
2626
@click.group()
27-
def dbt():
27+
@click.pass_context
28+
def dbt(ctx):
2829
"""DBT specific commands."""
30+
# Ensure context object exists
31+
ctx.ensure_object(dict)
2932

3033

3134
@dbt.command("project-health")
32-
@click.option("--token", required=False, help="Your API token for authentication.")
33-
@click.option("--instance-name", required=False, help="Your tenant ID.")
3435
@click.option(
3536
"--manifest-path",
3637
required=True,
@@ -57,21 +58,24 @@ def dbt():
5758
default=None,
5859
help="Selective model testing. Specify one or more models to run tests on.",
5960
)
60-
@click.option("--backend-url", required=False, help="Altimate's Backend URL", default="https://api.myaltimate.com")
61+
@click.pass_context
6162
def project_health(
62-
token,
63-
instance_name,
63+
ctx,
6464
manifest_path,
6565
catalog_path,
6666
config_path=None,
6767
config_name=None,
6868
select=None,
69-
backend_url="https://api.myaltimate.com",
7069
):
7170
"""
7271
Validate the DBT project's configuration and structure.
7372
:param manifest_path: Path to the DBT manifest file.
7473
"""
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")
78+
7579
config = None
7680
if config_path:
7781
config = load_config(config_path)
@@ -131,8 +135,7 @@ def project_health(
131135

132136

133137
@dbt.command("onboard")
134-
@click.option("--token", prompt="API Token", help="Your API token for authentication.")
135-
@click.option("--instance-name", prompt="Instance Name", help="Your tenant ID.")
138+
@click.option("--dbt_core_integration_id", prompt="DBT Core Integration ID", help="DBT Core Integration ID")
136139
@click.option(
137140
"--dbt_core_integration_id",
138141
"--dbt_integration_id",
@@ -150,17 +153,26 @@ def project_health(
150153
)
151154
@click.option("--manifest-path", required=True, prompt="Manifest Path", help="Path to the manifest file.")
152155
@click.option("--catalog-path", required=False, prompt=False, help="Path to the catalog file.")
153-
@click.option("--backend-url", required=False, help="Altimate's Backend URL", default="https://api.myaltimate.com")
156+
@click.pass_context
154157
def onboard(
155-
token,
156-
instance_name,
157-
dbt_integration_id, # Updated parameter name
158-
dbt_integration_environment, # Updated parameter name
158+
ctx,
159+
dbt_integration_id,
160+
dbt_integration_environment,
159161
manifest_path,
160162
catalog_path,
161-
backend_url="https://api.myaltimate.com",
162163
):
163164
"""Onboard a manifest file to DBT."""
165+
# Get common options from parent context
166+
token = ctx.parent.obj.get("token")
167+
instance_name = ctx.parent.obj.get("instance_name")
168+
backend_url = ctx.parent.obj.get("backend_url")
169+
170+
# For onboard command, token and instance_name are required
171+
if not token:
172+
token = click.prompt("API Token")
173+
if not instance_name:
174+
instance_name = click.prompt("Instance Name")
175+
164176
check_token_and_instance(token, instance_name)
165177

166178
if not validate_credentials(token, backend_url, instance_name):

0 commit comments

Comments
 (0)