Skip to content

Commit fcf6e6c

Browse files
authored
feat: add knowledge CLI command group (#55)
1 parent beb191e commit fcf6e6c

File tree

4 files changed

+134
-0
lines changed

4 files changed

+134
-0
lines changed

src/datapilot/cli/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import click
77
from dotenv import load_dotenv
88

9+
from datapilot import __version__
10+
from datapilot.core.knowledge.cli import cli as knowledge
911
from datapilot.core.mcp_utils.mcp import mcp
1012
from datapilot.core.platforms.dbt.cli.cli import dbt
1113

@@ -50,6 +52,7 @@ def process_config(config):
5052

5153

5254
@click.group()
55+
@click.version_option(version=__version__, prog_name="datapilot")
5356
@click.option("--token", required=False, help="Your API token for authentication.", hide_input=True)
5457
@click.option("--instance-name", required=False, help="Your tenant ID.")
5558
@click.option("--backend-url", required=False, help="Altimate's Backend URL", default="https://api.myaltimate.com")
@@ -90,3 +93,4 @@ def datapilot(ctx, token, instance_name, backend_url):
9093

9194
datapilot.add_command(dbt)
9295
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}")

0 commit comments

Comments
 (0)