dbt-agent-layer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dbt_agent_layer/__init__.py +3 -0
- dbt_agent_layer/cli/__init__.py +5 -0
- dbt_agent_layer/cli/build.py +138 -0
- dbt_agent_layer/cli/init.py +74 -0
- dbt_agent_layer/cli/main.py +18 -0
- dbt_agent_layer/cli/serve.py +181 -0
- dbt_agent_layer/config/__init__.py +5 -0
- dbt_agent_layer/config/settings.py +183 -0
- dbt_agent_layer/executor/__init__.py +6 -0
- dbt_agent_layer/executor/base.py +19 -0
- dbt_agent_layer/executor/bigquery_exec.py +73 -0
- dbt_agent_layer/executor/duckdb_exec.py +60 -0
- dbt_agent_layer/executor/factory.py +69 -0
- dbt_agent_layer/executor/postgres_exec.py +70 -0
- dbt_agent_layer/executor/snowflake_exec.py +83 -0
- dbt_agent_layer/generator/__init__.py +17 -0
- dbt_agent_layer/generator/anomaly.py +145 -0
- dbt_agent_layer/generator/delta.py +146 -0
- dbt_agent_layer/generator/narrative.py +93 -0
- dbt_agent_layer/generator/tool_builder.py +479 -0
- dbt_agent_layer/mcp/__init__.py +5 -0
- dbt_agent_layer/mcp/schema.py +81 -0
- dbt_agent_layer/mcp/server.py +171 -0
- dbt_agent_layer/parser/__init__.py +7 -0
- dbt_agent_layer/parser/manifest.py +229 -0
- dbt_agent_layer/parser/models.py +56 -0
- dbt_agent_layer/parser/project.py +54 -0
- dbt_agent_layer/parser/semantic_layer.py +140 -0
- dbt_agent_layer-0.1.0.dist-info/METADATA +267 -0
- dbt_agent_layer-0.1.0.dist-info/RECORD +32 -0
- dbt_agent_layer-0.1.0.dist-info/WHEEL +4 -0
- dbt_agent_layer-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""dbt-agent build command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.option(
|
|
16
|
+
"--project-dir",
|
|
17
|
+
default=".",
|
|
18
|
+
show_default=True,
|
|
19
|
+
help="Path to dbt project directory.",
|
|
20
|
+
type=click.Path(file_okay=False),
|
|
21
|
+
)
|
|
22
|
+
@click.option(
|
|
23
|
+
"--skip-compile",
|
|
24
|
+
is_flag=True,
|
|
25
|
+
default=False,
|
|
26
|
+
help="Use existing manifest.json; skip dbt compile.",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--dry-run",
|
|
30
|
+
is_flag=True,
|
|
31
|
+
default=False,
|
|
32
|
+
help="Print what would be generated; write nothing.",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--verbose",
|
|
36
|
+
is_flag=True,
|
|
37
|
+
default=False,
|
|
38
|
+
help="Print each metric as it is parsed.",
|
|
39
|
+
)
|
|
40
|
+
def build_cmd(
|
|
41
|
+
project_dir: str,
|
|
42
|
+
skip_compile: bool,
|
|
43
|
+
dry_run: bool,
|
|
44
|
+
verbose: bool,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Parse the dbt project and generate MCP tool metadata."""
|
|
47
|
+
from ..config.settings import load_config
|
|
48
|
+
from ..parser.project import DbtProject
|
|
49
|
+
from ..parser.manifest import DbtManifest
|
|
50
|
+
|
|
51
|
+
project_path = Path(project_dir).expanduser().resolve()
|
|
52
|
+
config = load_config(str(project_path))
|
|
53
|
+
|
|
54
|
+
# 1. Compile dbt if needed
|
|
55
|
+
if not skip_compile:
|
|
56
|
+
with console.status("[bold blue]Running dbt compile...[/bold blue]"):
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["dbt", "compile", "--project-dir", str(project_path)],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
)
|
|
62
|
+
if result.returncode != 0:
|
|
63
|
+
console.print("[yellow]dbt compile failed — trying existing manifest.json[/yellow]")
|
|
64
|
+
console.print(f"[dim]{result.stderr[:500]}[/dim]")
|
|
65
|
+
|
|
66
|
+
# 2. Find manifest
|
|
67
|
+
try:
|
|
68
|
+
dbt_project = DbtProject(str(project_path))
|
|
69
|
+
manifest_path = dbt_project.find_manifest()
|
|
70
|
+
except FileNotFoundError as e:
|
|
71
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
72
|
+
raise SystemExit(1)
|
|
73
|
+
|
|
74
|
+
# 3. Parse metrics
|
|
75
|
+
with console.status("[bold blue]Parsing metrics...[/bold blue]"):
|
|
76
|
+
manifest = DbtManifest(str(manifest_path))
|
|
77
|
+
metrics = manifest.get_metrics()
|
|
78
|
+
|
|
79
|
+
# Apply include/exclude
|
|
80
|
+
if config.include_metrics:
|
|
81
|
+
metrics = [m for m in metrics if m.name in config.include_metrics]
|
|
82
|
+
if config.exclude_metrics:
|
|
83
|
+
metrics = [m for m in metrics if m.name not in config.exclude_metrics]
|
|
84
|
+
|
|
85
|
+
if verbose:
|
|
86
|
+
for m in metrics:
|
|
87
|
+
console.print(
|
|
88
|
+
f" [green]✓[/green] [bold]{m.name}[/bold] "
|
|
89
|
+
f"({m.type}) — {m.description or m.label}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# 4. Write manifest cache
|
|
93
|
+
output_dir = project_path / "dbt_agent_tools"
|
|
94
|
+
if not dry_run:
|
|
95
|
+
output_dir.mkdir(exist_ok=True)
|
|
96
|
+
cache = {
|
|
97
|
+
"generated_at": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
|
|
98
|
+
"manifest_format": manifest.metric_format,
|
|
99
|
+
"metrics": [
|
|
100
|
+
{
|
|
101
|
+
"name": m.name,
|
|
102
|
+
"label": m.label,
|
|
103
|
+
"description": m.description,
|
|
104
|
+
"type": m.type,
|
|
105
|
+
"sql": m.sql,
|
|
106
|
+
"measure": m.measure,
|
|
107
|
+
"model": m.model,
|
|
108
|
+
"dimensions": m.dimensions,
|
|
109
|
+
"timestamp_column": m.timestamp_column,
|
|
110
|
+
"filters": m.filters,
|
|
111
|
+
"meta": m.meta,
|
|
112
|
+
"warehouse_table": m.warehouse_table,
|
|
113
|
+
"format": m.format,
|
|
114
|
+
"currency": m.currency,
|
|
115
|
+
}
|
|
116
|
+
for m in metrics
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
cache_path = output_dir / "manifest_cache.json"
|
|
120
|
+
cache_path.write_text(json.dumps(cache, indent=2))
|
|
121
|
+
|
|
122
|
+
# 5. Summary table
|
|
123
|
+
table = Table(title="Metrics discovered", show_lines=True)
|
|
124
|
+
table.add_column("Name", style="cyan")
|
|
125
|
+
table.add_column("Label")
|
|
126
|
+
table.add_column("Type", style="dim")
|
|
127
|
+
table.add_column("Dimensions", style="dim")
|
|
128
|
+
|
|
129
|
+
for m in metrics:
|
|
130
|
+
table.add_row(m.name, m.label, m.type, ", ".join(m.dimensions) or "—")
|
|
131
|
+
|
|
132
|
+
console.print(table)
|
|
133
|
+
|
|
134
|
+
action = "Would generate" if dry_run else "Generated"
|
|
135
|
+
console.print(
|
|
136
|
+
f"\n[bold green]{action}[/bold green] [bold]{len(metrics)}[/bold] metric tools"
|
|
137
|
+
+ (f" → [cyan]{output_dir}[/cyan]" if not dry_run else "")
|
|
138
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""dbt-agent init command."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.option(
|
|
14
|
+
"--project-dir",
|
|
15
|
+
default=".",
|
|
16
|
+
show_default=True,
|
|
17
|
+
help="Path to dbt project directory.",
|
|
18
|
+
type=click.Path(exists=True, file_okay=False),
|
|
19
|
+
)
|
|
20
|
+
@click.option(
|
|
21
|
+
"--adapter",
|
|
22
|
+
default=None,
|
|
23
|
+
help="Warehouse adapter (postgres|bigquery|snowflake|duckdb). Auto-detected if not set.",
|
|
24
|
+
type=click.Choice(["postgres", "bigquery", "snowflake", "duckdb"], case_sensitive=False),
|
|
25
|
+
)
|
|
26
|
+
def init_cmd(project_dir: str, adapter: str | None) -> None:
|
|
27
|
+
"""Initialise dbt-agent-layer in a dbt project."""
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from ..config.settings import write_default_config, _detect_adapter_from_profiles
|
|
31
|
+
|
|
32
|
+
project_path = Path(project_dir).expanduser().resolve()
|
|
33
|
+
|
|
34
|
+
# Detect dbt project
|
|
35
|
+
dbt_project_yml = project_path / "dbt_project.yml"
|
|
36
|
+
if not dbt_project_yml.exists():
|
|
37
|
+
console.print(
|
|
38
|
+
f"[red]Error:[/red] dbt_project.yml not found in {project_path}. "
|
|
39
|
+
"Are you inside a dbt project directory?"
|
|
40
|
+
)
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
|
|
43
|
+
with open(dbt_project_yml) as f:
|
|
44
|
+
dbt_cfg = yaml.safe_load(f) or {}
|
|
45
|
+
project_name = dbt_cfg.get("name", "unknown")
|
|
46
|
+
|
|
47
|
+
# Auto-detect adapter from profiles.yml
|
|
48
|
+
detected_adapter = adapter
|
|
49
|
+
if not detected_adapter:
|
|
50
|
+
target = dbt_cfg.get("target", "dev")
|
|
51
|
+
for profiles_dir in [project_path, Path.home() / ".dbt"]:
|
|
52
|
+
profiles_path = profiles_dir / "profiles.yml"
|
|
53
|
+
if profiles_path.exists():
|
|
54
|
+
detected_adapter, _ = _detect_adapter_from_profiles(profiles_path, target)
|
|
55
|
+
break
|
|
56
|
+
detected_adapter = detected_adapter or "duckdb"
|
|
57
|
+
|
|
58
|
+
# Write config
|
|
59
|
+
config_path = write_default_config(str(project_path), adapter_type=detected_adapter)
|
|
60
|
+
|
|
61
|
+
console.print(
|
|
62
|
+
Panel(
|
|
63
|
+
f"[bold green]dbt-agent-layer initialised[/bold green]\n\n"
|
|
64
|
+
f" dbt project : [cyan]{project_name}[/cyan]\n"
|
|
65
|
+
f" adapter : [cyan]{detected_adapter}[/cyan]\n"
|
|
66
|
+
f" config : [cyan]{config_path}[/cyan]\n\n"
|
|
67
|
+
"[bold]Next steps:[/bold]\n"
|
|
68
|
+
" 1. Edit [cyan]dbt-agent.yml[/cyan] if needed\n"
|
|
69
|
+
" 2. Run [bold]dbt-agent build[/bold] to generate tools\n"
|
|
70
|
+
" 3. Run [bold]dbt-agent serve[/bold] to start the MCP server",
|
|
71
|
+
title="dbt-agent init",
|
|
72
|
+
border_style="green",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Main CLI entrypoint — groups all dbt-agent commands."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .init import init_cmd
|
|
6
|
+
from .build import build_cmd
|
|
7
|
+
from .serve import serve_cmd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.version_option(package_name="dbt-agent-layer")
|
|
12
|
+
def cli() -> None:
|
|
13
|
+
"""dbt-agent-layer: Make your dbt metrics queryable by AI agents via MCP."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
cli.add_command(init_cmd, name="init")
|
|
17
|
+
cli.add_command(build_cmd, name="build")
|
|
18
|
+
cli.add_command(serve_cmd, name="serve")
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""dbt-agent serve command."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@click.option(
|
|
18
|
+
"--project-dir",
|
|
19
|
+
default=".",
|
|
20
|
+
show_default=True,
|
|
21
|
+
help="Path to dbt project directory.",
|
|
22
|
+
type=click.Path(file_okay=False),
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--port",
|
|
26
|
+
default=None,
|
|
27
|
+
type=int,
|
|
28
|
+
help="Override port from dbt-agent.yml (default: 8000).",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--transport",
|
|
32
|
+
default=None,
|
|
33
|
+
type=click.Choice(["stdio", "http"], case_sensitive=False),
|
|
34
|
+
help="MCP transport: stdio (Claude Desktop) or http.",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--skip-build",
|
|
38
|
+
is_flag=True,
|
|
39
|
+
default=False,
|
|
40
|
+
help="Use existing ./dbt_agent_tools/; skip build step.",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--reload",
|
|
44
|
+
is_flag=True,
|
|
45
|
+
default=False,
|
|
46
|
+
help="Hot-reload on dbt project changes (dev mode).",
|
|
47
|
+
)
|
|
48
|
+
def serve_cmd(
|
|
49
|
+
project_dir: str,
|
|
50
|
+
port: int | None,
|
|
51
|
+
transport: str | None,
|
|
52
|
+
skip_build: bool,
|
|
53
|
+
reload: bool,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Start the MCP server exposing all dbt metrics as callable tools."""
|
|
56
|
+
from ..config.settings import load_config
|
|
57
|
+
from ..executor.factory import get_executor
|
|
58
|
+
from ..parser.models import DbtMetric
|
|
59
|
+
|
|
60
|
+
project_path = Path(project_dir).expanduser().resolve()
|
|
61
|
+
config = load_config(str(project_path))
|
|
62
|
+
|
|
63
|
+
# Override config from CLI flags
|
|
64
|
+
if port is not None:
|
|
65
|
+
config.port = port
|
|
66
|
+
if transport is not None:
|
|
67
|
+
config.transport = transport
|
|
68
|
+
|
|
69
|
+
# --- Build step ---
|
|
70
|
+
metrics = _load_metrics(project_path, skip_build, config)
|
|
71
|
+
|
|
72
|
+
if not metrics:
|
|
73
|
+
console.print(
|
|
74
|
+
"[yellow]Warning:[/yellow] No metrics found. "
|
|
75
|
+
"Run [bold]dbt-agent build[/bold] or check your dbt project for metrics: blocks."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# --- Executor ---
|
|
79
|
+
try:
|
|
80
|
+
executor = get_executor(config)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
console.print(f"[red]Error creating executor:[/red] {e}")
|
|
83
|
+
raise SystemExit(1)
|
|
84
|
+
|
|
85
|
+
# --- Test connection ---
|
|
86
|
+
async def _test():
|
|
87
|
+
return await executor.test_connection()
|
|
88
|
+
|
|
89
|
+
ok = asyncio.run(_test())
|
|
90
|
+
if not ok:
|
|
91
|
+
console.print(
|
|
92
|
+
f"[yellow]Warning:[/yellow] Could not connect to {config.adapter_type}. "
|
|
93
|
+
"Metric queries will fail until the connection is available."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# --- Create MCP server ---
|
|
97
|
+
from ..mcp.server import create_server
|
|
98
|
+
|
|
99
|
+
server = create_server(metrics, executor, config)
|
|
100
|
+
|
|
101
|
+
console.print(
|
|
102
|
+
Panel(
|
|
103
|
+
f"[bold green]dbt-agent-layer MCP server starting[/bold green]\n\n"
|
|
104
|
+
f" metrics : [cyan]{len(metrics)}[/cyan]\n"
|
|
105
|
+
f" adapter : [cyan]{config.adapter_type}[/cyan]\n"
|
|
106
|
+
f" transport : [cyan]{config.transport}[/cyan]\n"
|
|
107
|
+
+ (f" port : [cyan]{config.port}[/cyan]\n" if config.transport == "http" else "")
|
|
108
|
+
+ "\n[bold]Connect with Claude Desktop:[/bold]\n"
|
|
109
|
+
' Add to claude_desktop_config.json → mcpServers → "dbt-metrics"',
|
|
110
|
+
title="dbt-agent serve",
|
|
111
|
+
border_style="blue",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# --- Start server ---
|
|
116
|
+
if config.transport == "http":
|
|
117
|
+
server.run(transport="streamable-http", host=config.host, port=config.port)
|
|
118
|
+
else:
|
|
119
|
+
server.run(transport="stdio")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_metrics(
|
|
123
|
+
project_path: Path, skip_build: bool, config: "AgentConfig" # noqa: F821
|
|
124
|
+
) -> list["DbtMetric"]: # noqa: F821
|
|
125
|
+
"""Load metrics from manifest cache or by running the build step."""
|
|
126
|
+
from ..parser.models import DbtMetric
|
|
127
|
+
|
|
128
|
+
# Try cached manifest first if skip_build
|
|
129
|
+
cache_path = project_path / "dbt_agent_tools" / "manifest_cache.json"
|
|
130
|
+
if skip_build and cache_path.exists():
|
|
131
|
+
try:
|
|
132
|
+
with open(cache_path) as f:
|
|
133
|
+
cache = json.load(f)
|
|
134
|
+
metrics = []
|
|
135
|
+
for m in cache.get("metrics", []):
|
|
136
|
+
metrics.append(
|
|
137
|
+
DbtMetric(
|
|
138
|
+
name=m["name"],
|
|
139
|
+
label=m["label"],
|
|
140
|
+
description=m.get("description"),
|
|
141
|
+
type=m.get("type", "simple"),
|
|
142
|
+
sql=m.get("sql"),
|
|
143
|
+
measure=m.get("measure"),
|
|
144
|
+
model=m.get("model"),
|
|
145
|
+
dimensions=m.get("dimensions", []),
|
|
146
|
+
timestamp_column=m.get("timestamp_column"),
|
|
147
|
+
filters=m.get("filters", []),
|
|
148
|
+
meta=m.get("meta", {}),
|
|
149
|
+
warehouse_table=m.get("warehouse_table"),
|
|
150
|
+
format=m.get("format"),
|
|
151
|
+
currency=m.get("currency"),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
console.print(
|
|
155
|
+
f"[dim]Loaded {len(metrics)} metrics from cache ({cache_path})[/dim]"
|
|
156
|
+
)
|
|
157
|
+
return metrics
|
|
158
|
+
except Exception as e:
|
|
159
|
+
console.print(f"[yellow]Cache load failed ({e}), rebuilding...[/yellow]")
|
|
160
|
+
|
|
161
|
+
# Build from manifest
|
|
162
|
+
from ..parser.project import DbtProject
|
|
163
|
+
from ..parser.manifest import DbtManifest
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
dbt_project = DbtProject(str(project_path))
|
|
167
|
+
manifest_path = dbt_project.find_manifest()
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
# Try schema file parsing as last resort
|
|
170
|
+
console.print(
|
|
171
|
+
"[yellow]manifest.json not found — scanning schema.yml files...[/yellow]"
|
|
172
|
+
)
|
|
173
|
+
from ..parser.semantic_layer import parse_schema_files
|
|
174
|
+
metrics = parse_schema_files(str(project_path))
|
|
175
|
+
return metrics
|
|
176
|
+
|
|
177
|
+
with console.status("[bold blue]Parsing metrics from manifest...[/bold blue]"):
|
|
178
|
+
manifest = DbtManifest(str(manifest_path))
|
|
179
|
+
metrics = manifest.get_metrics()
|
|
180
|
+
|
|
181
|
+
return metrics
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Configuration loading and management for dbt-agent-layer."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AgentConfig:
|
|
15
|
+
"""Full configuration for dbt-agent-layer."""
|
|
16
|
+
|
|
17
|
+
# dbt project
|
|
18
|
+
project_dir: str = "."
|
|
19
|
+
profiles_dir: str = "~/.dbt"
|
|
20
|
+
target: str = "dev"
|
|
21
|
+
|
|
22
|
+
# adapter
|
|
23
|
+
adapter_type: str = "duckdb"
|
|
24
|
+
adapter_kwargs: dict = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
# server
|
|
27
|
+
host: str = "0.0.0.0"
|
|
28
|
+
port: int = 8000
|
|
29
|
+
transport: str = "stdio"
|
|
30
|
+
|
|
31
|
+
# metric behaviour
|
|
32
|
+
default_period: str = "current_month"
|
|
33
|
+
compare_to: str = "prior_period"
|
|
34
|
+
include_metrics: list[str] = field(default_factory=list)
|
|
35
|
+
exclude_metrics: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
# narrative
|
|
38
|
+
narratives_enabled: bool = True
|
|
39
|
+
narrative_style: str = "concise"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_yaml(path: Path) -> dict:
|
|
43
|
+
"""Read a YAML file, returning empty dict on failure."""
|
|
44
|
+
try:
|
|
45
|
+
with open(path) as f:
|
|
46
|
+
return yaml.safe_load(f) or {}
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
return {}
|
|
49
|
+
except yaml.YAMLError as e:
|
|
50
|
+
logger.warning("Failed to parse YAML at %s: %s", path, e)
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _detect_adapter_from_profiles(profiles_path: Path, target: str) -> tuple[str, dict]:
|
|
55
|
+
"""
|
|
56
|
+
Read profiles.yml and extract adapter type + connection kwargs for a given target.
|
|
57
|
+
Returns ("duckdb", {}) as fallback.
|
|
58
|
+
"""
|
|
59
|
+
profiles = _read_yaml(profiles_path)
|
|
60
|
+
for profile_name, profile in profiles.items():
|
|
61
|
+
if not isinstance(profile, dict):
|
|
62
|
+
continue
|
|
63
|
+
outputs = profile.get("outputs", {})
|
|
64
|
+
if target in outputs:
|
|
65
|
+
cfg = outputs[target]
|
|
66
|
+
adapter = cfg.get("type", "duckdb")
|
|
67
|
+
return adapter, cfg
|
|
68
|
+
# also check any output if target not found
|
|
69
|
+
for output_name, cfg in outputs.items():
|
|
70
|
+
if isinstance(cfg, dict):
|
|
71
|
+
adapter = cfg.get("type", "duckdb")
|
|
72
|
+
return adapter, cfg
|
|
73
|
+
return "duckdb", {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_config(project_dir: str = ".") -> AgentConfig:
|
|
77
|
+
"""
|
|
78
|
+
Load configuration with priority (highest to lowest):
|
|
79
|
+
1. Environment variables (DBT_AGENT_*)
|
|
80
|
+
2. dbt-agent.yml in project_dir
|
|
81
|
+
3. Auto-detection from dbt_project.yml + profiles.yml
|
|
82
|
+
4. Defaults
|
|
83
|
+
|
|
84
|
+
Environment variables:
|
|
85
|
+
DBT_AGENT_ADAPTER_TYPE
|
|
86
|
+
DBT_AGENT_PORT
|
|
87
|
+
DBT_AGENT_TRANSPORT
|
|
88
|
+
DBT_AGENT_TARGET
|
|
89
|
+
DBT_AGENT_PROFILES_DIR
|
|
90
|
+
"""
|
|
91
|
+
project_path = Path(project_dir).expanduser().resolve()
|
|
92
|
+
config = AgentConfig(project_dir=str(project_path))
|
|
93
|
+
|
|
94
|
+
# --- Step 3: auto-detect from dbt_project.yml ---
|
|
95
|
+
dbt_project = _read_yaml(project_path / "dbt_project.yml")
|
|
96
|
+
if dbt_project:
|
|
97
|
+
config.target = dbt_project.get("target", config.target)
|
|
98
|
+
|
|
99
|
+
# --- Step 2: dbt-agent.yml ---
|
|
100
|
+
agent_yml = _read_yaml(project_path / "dbt-agent.yml")
|
|
101
|
+
if agent_yml:
|
|
102
|
+
proj = agent_yml.get("project", {})
|
|
103
|
+
config.project_dir = str(
|
|
104
|
+
(project_path / proj.get("dir", ".")).resolve()
|
|
105
|
+
)
|
|
106
|
+
config.profiles_dir = proj.get("profiles_dir", config.profiles_dir)
|
|
107
|
+
config.target = proj.get("target", config.target)
|
|
108
|
+
|
|
109
|
+
adapter = agent_yml.get("adapter", {})
|
|
110
|
+
if "type" in adapter:
|
|
111
|
+
config.adapter_type = adapter["type"]
|
|
112
|
+
|
|
113
|
+
server = agent_yml.get("server", {})
|
|
114
|
+
config.host = server.get("host", config.host)
|
|
115
|
+
config.port = int(server.get("port", config.port))
|
|
116
|
+
config.transport = server.get("transport", config.transport)
|
|
117
|
+
|
|
118
|
+
metrics = agent_yml.get("metrics", {})
|
|
119
|
+
config.include_metrics = metrics.get("include", config.include_metrics) or []
|
|
120
|
+
config.exclude_metrics = metrics.get("exclude", config.exclude_metrics) or []
|
|
121
|
+
config.default_period = metrics.get("default_period", config.default_period)
|
|
122
|
+
config.compare_to = metrics.get("compare_to", config.compare_to)
|
|
123
|
+
|
|
124
|
+
narratives = agent_yml.get("narratives", {})
|
|
125
|
+
config.narratives_enabled = narratives.get("enabled", config.narratives_enabled)
|
|
126
|
+
config.narrative_style = narratives.get("style", config.narrative_style)
|
|
127
|
+
|
|
128
|
+
# --- Auto-detect adapter from profiles.yml ---
|
|
129
|
+
profiles_dir = Path(config.profiles_dir).expanduser()
|
|
130
|
+
profiles_path = profiles_dir / "profiles.yml"
|
|
131
|
+
if profiles_path.exists():
|
|
132
|
+
detected_type, detected_kwargs = _detect_adapter_from_profiles(
|
|
133
|
+
profiles_path, config.target
|
|
134
|
+
)
|
|
135
|
+
if not agent_yml.get("adapter", {}).get("type"):
|
|
136
|
+
config.adapter_type = detected_type
|
|
137
|
+
config.adapter_kwargs = detected_kwargs
|
|
138
|
+
|
|
139
|
+
# --- Step 1: environment variables ---
|
|
140
|
+
if v := os.environ.get("DBT_AGENT_ADAPTER_TYPE"):
|
|
141
|
+
config.adapter_type = v
|
|
142
|
+
if v := os.environ.get("DBT_AGENT_PORT"):
|
|
143
|
+
config.port = int(v)
|
|
144
|
+
if v := os.environ.get("DBT_AGENT_TRANSPORT"):
|
|
145
|
+
config.transport = v
|
|
146
|
+
if v := os.environ.get("DBT_AGENT_TARGET"):
|
|
147
|
+
config.target = v
|
|
148
|
+
if v := os.environ.get("DBT_AGENT_PROFILES_DIR"):
|
|
149
|
+
config.profiles_dir = v
|
|
150
|
+
|
|
151
|
+
return config
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def write_default_config(project_dir: str, adapter_type: str = "duckdb") -> Path:
|
|
155
|
+
"""Write a default dbt-agent.yml to project_dir."""
|
|
156
|
+
config_path = Path(project_dir) / "dbt-agent.yml"
|
|
157
|
+
content = f"""version: 1
|
|
158
|
+
|
|
159
|
+
project:
|
|
160
|
+
dir: "." # path to dbt project
|
|
161
|
+
profiles_dir: "~/.dbt" # path to profiles.yml
|
|
162
|
+
target: "dev" # dbt target to use
|
|
163
|
+
|
|
164
|
+
adapter:
|
|
165
|
+
type: {adapter_type} # postgres | bigquery | snowflake | duckdb
|
|
166
|
+
|
|
167
|
+
server:
|
|
168
|
+
host: "0.0.0.0"
|
|
169
|
+
port: 8000
|
|
170
|
+
transport: "stdio" # stdio | http (stdio for Claude Desktop)
|
|
171
|
+
|
|
172
|
+
metrics:
|
|
173
|
+
include: [] # empty = all metrics
|
|
174
|
+
exclude: []
|
|
175
|
+
default_period: "current_month"
|
|
176
|
+
compare_to: "prior_period" # prior_period | prior_year | both
|
|
177
|
+
|
|
178
|
+
narratives:
|
|
179
|
+
enabled: true
|
|
180
|
+
style: "concise" # concise | detailed
|
|
181
|
+
"""
|
|
182
|
+
config_path.write_text(content)
|
|
183
|
+
return config_path
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Abstract base class for SQL executors."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseExecutor(ABC):
|
|
7
|
+
"""Abstract interface for warehouse SQL execution."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
async def execute(self, sql: str) -> list[dict]:
|
|
11
|
+
"""Execute SQL and return list of row dicts."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
async def test_connection(self) -> bool:
|
|
15
|
+
"""Ping the warehouse. Used on startup."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def get_adapter_type(self) -> str:
|
|
19
|
+
"""Returns 'postgres' | 'bigquery' | 'snowflake' | 'duckdb'."""
|