
Analyzing Data
- 1.2k installs
- 412 repo stars
- Updated July 27, 2026
- astronomer/agents
analyzing-data is an agent skill for queries data warehouse and answers business questions about data. handles questions requiring database/warehouse queries including "who uses x", "how many y", "show me z",.
About
The analyzing-data skill is designed for queries data warehouse and answers business questions about data. Handles questions requiring database/warehouse queries including "who uses X", "how many Y", "show me Z",. Data Analysis Answer business questions by querying the data warehouse. All CLI commands below are relative to this skill's directory. Invoke when the user asks about analyzing data or related SKILL.md workflows.
- Queries data warehouse and answers business questions about data. Handles questions requiring database/warehouse queries
- User asks about analyzing data or related SKILL.md workflows.
- Developers using analyzing data workflows documented in SKILL.md.
Analyzing Data by the numbers
- 1,231 all-time installs (skills.sh)
- +19 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #327 of 1,896 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
analyzing-data capabilities & compatibility
- Capabilities
- queries data warehouse and answers business ques · user asks about analyzing data or related skill. · developers using analyzing data workflows docume
- Use cases
- frontend
What analyzing-data says it does
Queries data warehouse and answers business questions about data. Handles questions requiring database/warehouse queries including "who uses X", "how many Y", "show me Z", "find cu
Queries data warehouse and answers business questions about data. Handles questions requiring database/warehouse queries including "who uses X", "how many Y", "
npx skills add https://github.com/astronomer/agents --skill analyzing-dataAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 412 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | astronomer/agents ↗ |
How do I queries data warehouse and answers business questions about data. handles questions requiring database/warehouse queries including "who uses x", "how many y", "show me z",?
Queries data warehouse and answers business questions about data. Handles questions requiring database/warehouse queries including "who uses X", "how many Y", "show me Z",.
Who is it for?
Developers using analyzing data workflows documented in SKILL.md.
Skip if: Skip when the task falls outside analyzing-data scope or needs a different stack.
When should I use this skill?
User asks about analyzing data or related SKILL.md workflows.
What you get
Completed analyzing-data workflow with documented commands, files, and expected deliverables.
- metric answers
- query results
Files
Data Analysis
Answer business questions by querying the data warehouse. The kernel auto-starts on first exec call.
All CLI commands below are relative to this skill's directory. Before running any scripts/cli.py command, cd to the directory containing this file.
Workflow
1. Pattern lookup — Check for a cached query strategy:
uv run scripts/cli.py pattern lookup "<user's question>"If a pattern exists, follow its strategy. Record the outcome after executing:
uv run scripts/cli.py pattern record <name> --success # or --failure2. Concept lookup — Find known table mappings:
uv run scripts/cli.py concept lookup <concept>3. Table discovery — If cache misses, search the codebase (Grep pattern="<concept>" glob="**/*.sql") or query INFORMATION_SCHEMA. See reference/discovery-warehouse.md.
4. Execute query:
uv run scripts/cli.py exec "df = run_sql('SELECT ...')"
uv run scripts/cli.py exec "print(df)"5. Cache learnings — Always cache before presenting results:
# Cache concept → table mapping
uv run scripts/cli.py concept learn <concept> <TABLE> -k <KEY_COL>
# Cache query strategy (if discovery was needed)
uv run scripts/cli.py pattern learn <name> -q "question" -s "step" -t "TABLE" -g "gotcha"6. Present findings to user.
Kernel Functions
| Function | Returns |
|---|---|
run_sql(query, limit=100) | Polars DataFrame |
run_sql_pandas(query, limit=100) | Pandas DataFrame |
pl (Polars) and pd (Pandas) are pre-imported.
CLI Reference
Kernel
uv run scripts/cli.py warehouse list # List warehouses
uv run scripts/cli.py start [-w name] # Start kernel (with optional warehouse)
uv run scripts/cli.py exec "..." # Execute Python code
uv run scripts/cli.py status # Kernel status
uv run scripts/cli.py restart # Restart kernel
uv run scripts/cli.py stop # Stop kernel
uv run scripts/cli.py install <pkg> # Install packageConcept Cache
uv run scripts/cli.py concept lookup <name> # Look up
uv run scripts/cli.py concept learn <name> <TABLE> -k <KEY_COL> # Learn
uv run scripts/cli.py concept list # List all
uv run scripts/cli.py concept import -p /path/to/warehouse.md # Bulk importPattern Cache
uv run scripts/cli.py pattern lookup "question" # Look up
uv run scripts/cli.py pattern learn <name> -q "..." -s "..." -t "TABLE" -g "gotcha" # Learn
uv run scripts/cli.py pattern record <name> --success # Record outcome
uv run scripts/cli.py pattern list # List all
uv run scripts/cli.py pattern delete <name> # DeleteTable Schema Cache
uv run scripts/cli.py table lookup <TABLE> # Look up schema
uv run scripts/cli.py table cache <TABLE> -c '[...]' # Cache schema
uv run scripts/cli.py table list # List cached
uv run scripts/cli.py table delete <TABLE> # DeleteCache Management
uv run scripts/cli.py cache status # Stats
uv run scripts/cli.py cache clear [--stale-only] # ClearReferences
- reference/discovery-warehouse.md — Large table handling, warehouse exploration, INFORMATION_SCHEMA queries
- reference/common-patterns.md — SQL templates for trends, comparisons, top-N, distributions, cohorts
Common Analysis Patterns
SQL templates for frequent analysis types.
Note: Examples use Snowflake syntax. For other databases:
-DATEADD(day, -7, x)→ PostgreSQL:x - INTERVAL '7 days'→ BigQuery:DATE_SUB(x, INTERVAL 7 DAY)
-DATE_TRUNC('week', x)→ BigQuery:DATE_TRUNC(x, WEEK)
Trend Over Time
SELECT
DATE_TRUNC('week', event_date) as week,
COUNT(*) as events,
COUNT(DISTINCT user_id) as unique_users
FROM events
WHERE event_date >= DATEADD(month, -3, CURRENT_DATE)
GROUP BY 1
ORDER BY 1Comparison (Period over Period)
SELECT
CASE
WHEN date_col >= DATEADD(day, -7, CURRENT_DATE) THEN 'This Week'
ELSE 'Last Week'
END as period,
SUM(amount) as total,
COUNT(DISTINCT customer_id) as customers
FROM orders
WHERE date_col >= DATEADD(day, -14, CURRENT_DATE)
GROUP BY 1Top N Analysis
SELECT
customer_name,
SUM(revenue) as total_revenue,
COUNT(*) as order_count
FROM orders
JOIN customers USING (customer_id)
WHERE order_date >= '2024-01-01'
GROUP BY customer_name
ORDER BY total_revenue DESC
LIMIT 10Distribution / Histogram
SELECT
FLOOR(amount / 100) * 100 as bucket,
COUNT(*) as frequency
FROM orders
GROUP BY 1
ORDER BY 1Cohort Analysis
WITH first_purchase AS (
SELECT
customer_id,
DATE_TRUNC('month', MIN(order_date)) as cohort_month
FROM orders
GROUP BY customer_id
)
SELECT
fp.cohort_month,
DATE_TRUNC('month', o.order_date) as activity_month,
COUNT(DISTINCT o.customer_id) as active_customers
FROM orders o
JOIN first_purchase fp USING (customer_id)
GROUP BY 1, 2
ORDER BY 1, 2Warehouse Discovery
Patterns for discovering and querying data in the warehouse.
Note: Examples use Snowflake syntax. Key differences for other databases:
-ILIKE→ BigQuery:LOWER(col) LIKE LOWER('%term%')
-DATEADD(day, -30, x)→ PostgreSQL:x - INTERVAL '30 days'
- INFORMATION_SCHEMA structure varies by databaseValue Discovery (Explore Before Filtering)
⚠️ CRITICAL: When filtering on categorical columns (operators, features, types, statuses), ALWAYS explore what values exist BEFORE writing your main query.
When the user asks about a specific item, it may be part of a family of related items. Run a discovery query first:
SELECT DISTINCT column_name, COUNT(*) as occurrences
FROM table
WHERE column_name ILIKE '%search_term%'
GROUP BY column_name
ORDER BY occurrences DESCThis pattern applies to:
- Operators/Features: Often have variants (Entry, Branch, Sensor, Pro, Lite)
- Statuses: May have related states (pending, pending_approval, pending_review)
- Types: Often have subtypes (user, user_admin, user_readonly)
- Products: May have tiers or editions
Fast Table Validation
Start with the simplest possible query, then add complexity only after each step succeeds:
Step 1: Does the data exist? → Simple LIMIT query, no JOINs
Step 2: How much data? → COUNT(*) with same filters
Step 3: What are the key IDs? → SELECT DISTINCT foreign_keys LIMIT 100
Step 4: Get related details → JOIN on the specific IDs from step 3Never jump from step 1 to complex aggregations. If step 1 returns 50 rows, use those IDs directly.
Use Row Counts as a Signal
- Millions+ rows → likely execution/fact data (actual events, transactions, runs)
- Thousands of rows → likely metadata/config (what's configured, not what happened)
Handling Large Tables (100M+ rows)
CRITICAL: Tables with 1B+ rows require special handling
1. Use simple queries only: SELECT col1, col2 FROM table WHERE filter LIMIT 100 2. NO JOINs, NO GROUP BY, NO aggregations on the first query 3. Only add complexity after the simple query succeeds
If your query times out, simplify it - don't give up. Remove JOINs, remove GROUP BY, add LIMIT.
Pattern: Find examples first, aggregate later
-- Step 1: Find examples (fast - stops after finding matches)
SELECT col_a, col_b, foreign_key_id
FROM huge_table
WHERE col_a ILIKE '%term%'
AND ts >= DATEADD(day, -30, CURRENT_DATE)
LIMIT 100
-- Step 2: Use foreign keys from step 1 to get details
SELECT o.name, o.details
FROM other_table o
WHERE o.id IN ('id1', 'id2', 'id3') -- IDs from step 1CRITICAL: LIMIT only helps without GROUP BY
-- STILL SLOW: LIMIT with GROUP BY - must scan ALL rows first
SELECT col, COUNT(*) FROM huge_table WHERE x ILIKE '%term%' GROUP BY col LIMIT 100
-- FAST: LIMIT without GROUP BY - stops after finding 100 rows
SELECT col, id FROM huge_table WHERE x ILIKE '%term%' LIMIT 100Table Exploration Process
Step 1: Search for Relevant Tables
SELECT
TABLE_CATALOG as database,
TABLE_SCHEMA as schema,
TABLE_NAME as table_name,
ROW_COUNT,
COMMENT as description
FROM <database>.INFORMATION_SCHEMA.TABLES
WHERE LOWER(TABLE_NAME) LIKE '%<concept>%'
OR LOWER(COMMENT) LIKE '%<concept>%'
ORDER BY TABLE_SCHEMA, TABLE_NAME
LIMIT 30Step 2: Categorize by Data Layer
| Layer | Naming Patterns | Purpose |
|---|---|---|
| Raw/Staging | raw_, stg_, staging_ | Source data, minimal transformation |
| Intermediate | int_, base_, prep_ | Cleaned, joined, business logic applied |
| Marts/Facts | fct_, fact_, mart_ | Business metrics, analysis-ready |
| Dimensions | dim_, dimension_ | Reference/lookup tables |
| Aggregates | agg_, summary_, daily_ | Pre-computed rollups |
Step 3: Get Schema Details
For the most relevant tables (typically 2-5), query column metadata:
SELECT COLUMN_NAME, DATA_TYPE, COMMENT
FROM <database>.INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = '<schema>' AND TABLE_NAME = '<table>'
ORDER BY ORDINAL_POSITIONStep 4: Check Data Freshness
SELECT
MAX(<timestamp_column>) as last_update,
COUNT(*) as row_count
FROM <table>uv.lock
"""Persistent cache for concepts, patterns, and table schemas.
Cache files are stored at ~/.astro/ai/cache/:
- concepts.json: concept → table mapping (e.g., "customers" → "HQ.MODEL.ORGS")
- patterns.json: question type → query strategy
- tables.json: table schema cache (columns, types, row counts)
"""
import json
from datetime import datetime, timedelta
from pathlib import Path
CACHE_DIR = Path.home() / ".astro" / "ai" / "cache"
# Default TTL for cache entries
DEFAULT_TTL_DAYS = 90
def _ensure_cache_dir():
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def _load_json(filename: str) -> dict:
path = CACHE_DIR / filename
if path.exists():
return json.loads(path.read_text())
return {}
def _save_json(filename: str, data: dict):
_ensure_cache_dir()
path = CACHE_DIR / filename
path.write_text(json.dumps(data, indent=2, default=str))
# --- Concept Cache ---
def lookup_concept(concept: str) -> dict | None:
"""Look up a concept (e.g., 'customers') to find its table."""
concepts = _load_json("concepts.json")
return concepts.get(concept.lower().strip())
def learn_concept(
concept: str,
table: str,
key_column: str | None = None,
date_column: str | None = None,
):
"""Store a concept -> table mapping for future use."""
concepts = _load_json("concepts.json")
concepts[concept.lower().strip()] = {
"table": table,
"key_column": key_column,
"date_column": date_column,
"learned_at": datetime.now().isoformat(),
}
_save_json("concepts.json", concepts)
return concepts[concept.lower().strip()]
def list_concepts() -> dict:
"""List all learned concepts."""
return _load_json("concepts.json")
# --- Pattern Cache ---
def lookup_pattern(question: str) -> list[dict]:
"""Find patterns that match a question. Returns list of matching patterns."""
patterns = _load_json("patterns.json")
question_lower = question.lower()
matches = []
for name, pattern in patterns.items():
for qtype in pattern.get("question_types", []):
keywords = qtype.lower().replace("x", "").split()
if all(kw in question_lower for kw in keywords if len(kw) > 2):
matches.append({"name": name, **pattern})
break
return sorted(matches, key=lambda p: p.get("success_count", 0), reverse=True)
def learn_pattern(
name: str,
question_types: list[str],
strategy: list[str],
tables_used: list[str],
gotchas: list[str],
example_query: str | None = None,
):
"""Store a query pattern/strategy for a type of question."""
patterns = _load_json("patterns.json")
patterns[name.lower().strip()] = {
"question_types": question_types,
"strategy": strategy,
"tables_used": tables_used,
"gotchas": gotchas,
"example_query": example_query,
"created_at": datetime.now().isoformat(),
"success_count": 1,
"failure_count": 0,
}
_save_json("patterns.json", patterns)
return patterns[name.lower().strip()]
def record_pattern_outcome(name: str, success: bool):
"""Record whether a pattern helped or failed."""
patterns = _load_json("patterns.json")
key = name.lower().strip()
if key in patterns:
if success:
patterns[key]["success_count"] = patterns[key].get("success_count", 0) + 1
else:
patterns[key]["failure_count"] = patterns[key].get("failure_count", 0) + 1
_save_json("patterns.json", patterns)
return patterns[key]
return None
def list_patterns() -> dict:
"""List all learned patterns."""
return _load_json("patterns.json")
def delete_pattern(name: str) -> bool:
"""Delete a pattern by name. Returns True if it existed."""
patterns = _load_json("patterns.json")
key = name.lower().strip()
if key in patterns:
del patterns[key]
_save_json("patterns.json", patterns)
return True
return False
# --- Cache Management ---
def _is_stale(learned_at: str, ttl_days: int = DEFAULT_TTL_DAYS) -> bool:
"""Check if an entry is older than TTL."""
try:
learned = datetime.fromisoformat(learned_at)
return datetime.now() - learned > timedelta(days=ttl_days)
except (ValueError, TypeError):
return False
def cache_stats() -> dict:
"""Get cache statistics."""
concepts = _load_json("concepts.json")
patterns = _load_json("patterns.json")
stale_concepts = sum(
1 for c in concepts.values() if _is_stale(c.get("learned_at", ""))
)
stale_patterns = sum(
1 for p in patterns.values() if _is_stale(p.get("created_at", ""))
)
return {
"concepts_count": len(concepts),
"patterns_count": len(patterns),
"stale_concepts": stale_concepts,
"stale_patterns": stale_patterns,
"cache_dir": str(CACHE_DIR),
"ttl_days": DEFAULT_TTL_DAYS,
}
def clear_cache(cache_type: str = "all", purge_stale_only: bool = False) -> dict:
"""Clear cache entries.
Args:
cache_type: "all", "concepts", or "patterns"
purge_stale_only: If True, only remove entries older than TTL
Returns:
Summary of what was cleared
"""
result = {"concepts_cleared": 0, "patterns_cleared": 0}
if cache_type in ("all", "concepts"):
concepts = _load_json("concepts.json")
if purge_stale_only:
original = len(concepts)
concepts = {
k: v
for k, v in concepts.items()
if not _is_stale(v.get("learned_at", ""))
}
result["concepts_cleared"] = original - len(concepts)
_save_json("concepts.json", concepts)
else:
result["concepts_cleared"] = len(concepts)
_save_json("concepts.json", {})
if cache_type in ("all", "patterns"):
patterns = _load_json("patterns.json")
if purge_stale_only:
original = len(patterns)
patterns = {
k: v
for k, v in patterns.items()
if not _is_stale(v.get("created_at", ""))
}
result["patterns_cleared"] = original - len(patterns)
_save_json("patterns.json", patterns)
else:
result["patterns_cleared"] = len(patterns)
_save_json("patterns.json", {})
return result
# --- Table Schema Cache ---
def get_table(full_name: str) -> dict | None:
"""Get cached table schema by full name (DATABASE.SCHEMA.TABLE)."""
tables = _load_json("tables.json")
return tables.get(full_name.upper())
def set_table(
full_name: str,
columns: list[dict],
row_count: int | None = None,
comment: str | None = None,
) -> dict:
"""Cache a table's schema.
Args:
full_name: Full table name (DATABASE.SCHEMA.TABLE)
columns: List of column dicts [{name, type, nullable, comment}, ...]
row_count: Optional row count
comment: Optional table description
Returns:
The cached table entry
"""
tables = _load_json("tables.json")
entry = {
"full_name": full_name.upper(),
"columns": columns,
"row_count": row_count,
"comment": comment,
"cached_at": datetime.now().isoformat(),
}
tables[full_name.upper()] = entry
_save_json("tables.json", tables)
return entry
def list_tables() -> dict:
"""List all cached table schemas."""
return _load_json("tables.json")
def delete_table(full_name: str) -> bool:
"""Remove a table from cache. Returns True if it existed."""
tables = _load_json("tables.json")
key = full_name.upper()
if key in tables:
del tables[key]
_save_json("tables.json", tables)
return True
return False
# --- Bulk Import ---
def load_concepts_from_warehouse_md(path: Path | None = None) -> int:
"""Parse warehouse.md and populate cache with Quick Reference entries.
Looks for a markdown table with columns: Concept | Table | Key Column | Date Column
Args:
path: Path to warehouse.md. If None, searches common locations.
Returns:
Number of concepts loaded into cache.
"""
import re
# Find warehouse.md if not provided
if path is None:
locations = [
Path(".astro/warehouse.md"),
Path.home() / ".astro" / "agents" / "warehouse.md",
Path("warehouse.md"),
]
for loc in locations:
if loc.exists():
path = loc
break
if path is None or not path.exists():
return 0
content = path.read_text(encoding="utf-8")
concepts_loaded = 0
# Find markdown table rows: | concept | table | key_col | date_col |
# Skip header rows (contain "Concept" or "---")
table_pattern = re.compile(
r"^\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|(?:\s*([^|]*)\s*\|)?(?:\s*([^|]*)\s*\|)?",
re.MULTILINE,
)
for match in table_pattern.finditer(content):
concept = match.group(1).strip()
table = match.group(2).strip()
key_col = match.group(3).strip() if match.group(3) else None
date_col = match.group(4).strip() if match.group(4) else None
# Skip header/separator rows
if not concept or concept.lower() == "concept" or "---" in concept:
continue
if not table or table.lower() == "table" or "---" in table:
continue
# Skip if table doesn't look valid (should have dots for fully qualified name)
if "." not in table:
continue
# Normalize empty values
if key_col in ("-", "", None):
key_col = None
if date_col in ("-", "", None):
date_col = None
learn_concept(concept, table, key_col, date_col)
concepts_loaded += 1
return concepts_loaded
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "click>=8.0.0",
# "jupyter-client>=8.0.0",
# "ipykernel>=6.0.0",
# "pyyaml>=6.0",
# "python-dotenv>=1.0.0",
# "cryptography>=41.0.0",
# ]
# ///
"""CLI for the analyzing-data skill.
Usage:
uv run scripts/cli.py start # Start kernel with Snowflake
uv run scripts/cli.py exec "df = run_sql('SELECT ...')"
uv run scripts/cli.py status # Check kernel status
uv run scripts/cli.py stop # Stop kernel
"""
import json
import shutil
import sys
import click
# Add parent directory to path for lib imports
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from kernel import KernelManager
from warehouse import WarehouseConfig
import cache
def check_uv_installed():
"""Check if uv is installed and provide helpful error if not."""
if not shutil.which("uv"):
click.echo("Error: uv is not installed.", err=True)
click.echo(
"Install with: curl -LsSf https://astral.sh/uv/install.sh | sh", err=True
)
sys.exit(1)
@click.group()
@click.version_option(version="0.1.0")
def main():
"""Jupyter kernel CLI for data analysis with Snowflake."""
pass
@main.group()
def warehouse():
"""Manage warehouse connections."""
@warehouse.command("list")
def warehouse_list():
"""List available warehouse connections."""
try:
config = WarehouseConfig.load()
if not config.connectors:
click.echo("No warehouses configured")
return
default_name, _ = config.get_default()
for name, conn in config.connectors.items():
marker = " (default)" if name == default_name else ""
click.echo(f"{name}: {conn.connector_type()}{marker}")
except FileNotFoundError:
click.echo("No warehouse config found at ~/.astro/agents/warehouse.yml")
except Exception as e:
click.echo(f"Error: {e}", err=True)
@main.command()
@click.option("--warehouse", "-w", help="Warehouse name from config")
def start(warehouse: str | None):
"""Start kernel with Snowflake connection."""
check_uv_installed()
km = KernelManager()
if km.is_running:
click.echo("Kernel already running")
return
try:
config = WarehouseConfig.load()
wh_name, wh_config = (
(warehouse, config.connectors[warehouse])
if warehouse
else config.get_default()
)
click.echo(f"Using warehouse: {wh_name}")
except FileNotFoundError as e:
click.echo(f"Error: {e}", err=True)
click.echo(
"Create ~/.astro/agents/warehouse.yml with your Snowflake credentials",
err=True,
)
sys.exit(1)
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
env_vars = wh_config.get_env_vars_for_kernel()
extra_packages = wh_config.get_required_packages()
km.start(env_vars=env_vars, extra_packages=extra_packages)
result = km.execute(wh_config.to_python_prelude(), timeout=60.0)
if not result.success:
click.echo(f"Connection error:\n{result.error}", err=True)
km.stop()
sys.exit(1)
click.echo(result.output)
@main.command("exec")
@click.argument("code")
@click.option("--timeout", "-t", default=30.0, help="Timeout in seconds")
def execute(code: str, timeout: float):
"""Execute Python code in the kernel. Auto-starts kernel if not running."""
km = KernelManager()
if not km.is_running:
check_uv_installed()
try:
config = WarehouseConfig.load()
wh_name, wh_config = config.get_default()
click.echo(f"Starting kernel with: {wh_name}", err=True)
env_vars = wh_config.get_env_vars_for_kernel()
extra_packages = wh_config.get_required_packages()
km.start(env_vars=env_vars, extra_packages=extra_packages)
result = km.execute(wh_config.to_python_prelude(), timeout=60.0)
if result.output:
click.echo(result.output, err=True)
if not result.success:
click.echo(f"Connection error:\n{result.error}", err=True)
km.stop()
sys.exit(1)
except Exception as e:
click.echo(f"Error starting kernel: {e}", err=True)
sys.exit(1)
result = km.execute(code, timeout=timeout)
if result.output:
click.echo(result.output, nl=False)
if result.error:
click.echo(result.error, err=True)
sys.exit(1)
@main.command()
def stop():
"""Stop the kernel."""
KernelManager().stop()
@main.command()
def restart():
"""Restart the kernel (stop + start)."""
km = KernelManager()
km.stop()
try:
config = WarehouseConfig.load()
wh_name, wh_config = config.get_default()
click.echo(f"Restarting kernel with: {wh_name}")
env_vars = wh_config.get_env_vars_for_kernel()
extra_packages = wh_config.get_required_packages()
km.start(env_vars=env_vars, extra_packages=extra_packages)
result = km.execute(wh_config.to_python_prelude(), timeout=60.0)
if result.output:
click.echo(result.output)
if not result.success:
click.echo(f"Connection error:\n{result.error}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
@main.command()
@click.option("--json", "as_json", is_flag=True)
def status(as_json: bool):
"""Check kernel status."""
info = KernelManager().status()
if as_json:
click.echo(json.dumps(info, indent=2))
else:
if info["running"]:
click.echo(
f"Kernel: {'running' if info['responsive'] else 'running (unresponsive)'}"
)
else:
click.echo("Kernel: not running")
@main.command("install")
@click.argument("packages", nargs=-1, required=True)
def install_packages(packages: tuple):
"""Install additional packages into the kernel environment.
Example: uv run scripts/cli.py install plotly scipy
"""
km = KernelManager()
success, message = km.install_packages(list(packages))
if success:
click.echo(message)
else:
click.echo(f"Error: {message}", err=True)
sys.exit(1)
@main.command()
def ensure():
"""Ensure kernel is running (start if needed). Used by hooks."""
check_uv_installed()
km = KernelManager()
if km.is_running:
return
try:
config = WarehouseConfig.load()
wh_name, wh_config = config.get_default()
click.echo(f"Starting kernel with: {wh_name}", err=True)
env_vars = wh_config.get_env_vars_for_kernel()
extra_packages = wh_config.get_required_packages()
km.start(env_vars=env_vars, extra_packages=extra_packages)
result = km.execute(wh_config.to_python_prelude(), timeout=60.0)
if result.output:
click.echo(result.output, err=True)
except Exception as e:
click.echo(f"Warning: {e}", err=True)
km.start()
@main.group()
def concept():
"""Manage concept cache (concept -> table mappings)."""
pass
@concept.command("lookup")
@click.argument("name")
def concept_lookup(name: str):
"""Look up a concept to find its table."""
result = cache.lookup_concept(name)
if result:
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Concept '{name}' not found")
@concept.command("learn")
@click.argument("name")
@click.argument("table")
@click.option("--key-column", "-k", help="Primary key column")
@click.option("--date-column", "-d", help="Date column for filtering")
def concept_learn(name: str, table: str, key_column: str, date_column: str):
"""Store a concept -> table mapping."""
cache.learn_concept(name, table, key_column, date_column)
click.echo(f"Learned: '{name}' -> {table}")
@concept.command("list")
def concept_list():
"""List all learned concepts."""
concepts = cache.list_concepts()
if concepts:
click.echo(json.dumps(concepts, indent=2))
else:
click.echo("No concepts cached yet")
@main.group()
def pattern():
"""Manage pattern cache (query strategies)."""
pass
@pattern.command("lookup")
@click.argument("question")
def pattern_lookup(question: str):
"""Find patterns matching a question."""
matches = cache.lookup_pattern(question)
if matches:
click.echo(json.dumps(matches, indent=2))
else:
click.echo("No matching patterns found")
@pattern.command("learn")
@click.argument("name")
@click.option(
"--question-types",
"-q",
multiple=True,
required=True,
help="Question types this pattern handles",
)
@click.option("--strategy", "-s", multiple=True, required=True, help="Strategy steps")
@click.option("--tables", "-t", multiple=True, required=True, help="Tables used")
@click.option("--gotchas", "-g", multiple=True, help="Gotchas/warnings")
@click.option("--example", "-e", help="Example SQL query")
def pattern_learn(
name: str,
question_types: tuple,
strategy: tuple,
tables: tuple,
gotchas: tuple,
example: str,
):
"""Store a query pattern/strategy."""
cache.learn_pattern(
name=name,
question_types=list(question_types),
strategy=list(strategy),
tables_used=list(tables),
gotchas=list(gotchas),
example_query=example,
)
click.echo(f"Learned pattern: '{name}'")
@pattern.command("record")
@click.argument("name")
@click.option("--success/--failure", default=True, help="Record success or failure")
def pattern_record(name: str, success: bool):
"""Record pattern outcome (success/failure)."""
result = cache.record_pattern_outcome(name, success)
if result:
click.echo(f"Recorded {'success' if success else 'failure'} for '{name}'")
else:
click.echo(f"Pattern '{name}' not found")
@pattern.command("list")
def pattern_list():
"""List all learned patterns."""
patterns = cache.list_patterns()
if patterns:
click.echo(json.dumps(patterns, indent=2))
else:
click.echo("No patterns cached yet")
@pattern.command("delete")
@click.argument("name")
def pattern_delete(name: str):
"""Delete a pattern by name."""
if cache.delete_pattern(name):
click.echo(f"Deleted pattern: '{name}'")
else:
click.echo(f"Pattern '{name}' not found")
# --- Cache Management ---
@main.group("cache")
def cache_group():
"""Manage cache (status, clear)."""
pass
@cache_group.command("status")
def cache_status():
"""Show cache statistics."""
stats = cache.cache_stats()
click.echo(json.dumps(stats, indent=2))
@cache_group.command("clear")
@click.option(
"--type",
"cache_type",
type=click.Choice(["all", "concepts", "patterns"]),
default="all",
help="What to clear",
)
@click.option("--stale-only", is_flag=True, help="Only clear entries older than TTL")
@click.confirmation_option(prompt="Are you sure you want to clear the cache?")
def cache_clear(cache_type: str, stale_only: bool):
"""Clear cache entries."""
result = cache.clear_cache(cache_type, purge_stale_only=stale_only)
click.echo(
f"Cleared {result['concepts_cleared']} concepts, "
f"{result['patterns_cleared']} patterns"
)
# --- Table Schema Cache ---
@main.group()
def table():
"""Manage table schema cache."""
pass
@table.command("lookup")
@click.argument("full_name")
def table_lookup(full_name: str):
"""Look up a cached table schema (DATABASE.SCHEMA.TABLE)."""
result = cache.get_table(full_name)
if result:
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Table '{full_name}' not in cache")
@table.command("cache")
@click.argument("full_name")
@click.option("--columns", "-c", help="JSON array of column definitions")
@click.option("--row-count", "-r", type=int, help="Row count")
@click.option("--comment", help="Table description")
def table_cache(full_name: str, columns: str, row_count: int, comment: str):
"""Cache a table's schema.
Example: uv run scripts/cli.py table cache DB.SCHEMA.TABLE -c '[{"name":"id","type":"INT"}]'
"""
if columns:
cols = json.loads(columns)
else:
cols = []
cache.set_table(full_name, cols, row_count, comment)
click.echo(f"Cached table: '{full_name}'")
@table.command("list")
def table_list():
"""List all cached table schemas."""
tables = cache.list_tables()
if tables:
# Show summary (name + column count + cached_at)
for name, info in tables.items():
col_count = len(info.get("columns", []))
cached_at = info.get("cached_at", "unknown")[:10]
click.echo(f"{name}: {col_count} columns (cached {cached_at})")
else:
click.echo("No tables cached yet")
@table.command("delete")
@click.argument("full_name")
def table_delete(full_name: str):
"""Remove a table from cache."""
if cache.delete_table(full_name):
click.echo(f"Deleted table: '{full_name}'")
else:
click.echo(f"Table '{full_name}' not found")
# --- Bulk Import ---
@concept.command("import")
@click.option("--path", "-p", type=click.Path(exists=True), help="Path to warehouse.md")
def concept_import(path: str):
"""Import concepts from warehouse.md Quick Reference table.
Parses markdown tables with: | Concept | Table | Key Column | Date Column |
"""
from pathlib import Path as P
file_path = P(path) if path else None
count = cache.load_concepts_from_warehouse_md(file_path)
if count > 0:
click.echo(f"Imported {count} concepts from warehouse.md")
else:
click.echo("No concepts found in warehouse.md")
if __name__ == "__main__":
main()
"""Configuration utilities for the analyzing-data skill."""
import sys
import warnings
from pathlib import Path
# Legacy path (deprecated)
_LEGACY_CONFIG_DIR = Path.home() / ".astro" / "ai" / "config"
# New path
_NEW_CONFIG_DIR = Path.home() / ".astro" / "agents"
_legacy_warning_shown = False
def _check_legacy_path() -> Path | None:
"""Check if legacy config path exists and warn user to migrate.
Returns the legacy path if it exists and should be used, None otherwise.
"""
global _legacy_warning_shown
if _LEGACY_CONFIG_DIR.exists() and not _NEW_CONFIG_DIR.exists():
if not _legacy_warning_shown:
warnings.warn(
f"Deprecated config path: {_LEGACY_CONFIG_DIR}\n"
f" Please move your config to: {_NEW_CONFIG_DIR}\n"
f" Run: mv ~/.astro/ai/config ~/.astro/agents",
DeprecationWarning,
stacklevel=3,
)
# Also print to stderr for CLI visibility
print(
"WARNING: Using deprecated config path ~/.astro/ai/config/\n"
" Please migrate: mv ~/.astro/ai/config ~/.astro/agents",
file=sys.stderr,
)
_legacy_warning_shown = True
return _LEGACY_CONFIG_DIR
return None
def get_kernel_venv_dir() -> Path:
"""Get the path to the kernel virtual environment directory."""
legacy = _check_legacy_path()
if legacy:
return legacy.parent / "kernel_venv"
return _NEW_CONFIG_DIR / "kernel_venv"
def get_kernel_connection_file() -> Path:
"""Get the path to the kernel connection file."""
legacy = _check_legacy_path()
if legacy:
return legacy.parent / "kernel.json"
return _NEW_CONFIG_DIR / "kernel.json"
def get_config_dir() -> Path:
"""Get the path to the config directory."""
legacy = _check_legacy_path()
if legacy:
return legacy
return _NEW_CONFIG_DIR
"""Database connector registry, base class, and all connector implementations."""
import os
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, NamedTuple, TypeVar
# --- Base class ---
@dataclass
class DatabaseConnector(ABC):
"""Base class for database connectors."""
databases: list[str]
@classmethod
@abstractmethod
def connector_type(cls) -> str:
"""Return type identifier (e.g., 'snowflake', 'postgres')."""
@classmethod
@abstractmethod
def from_dict(cls, data: dict[str, Any]) -> "DatabaseConnector":
"""Create from config dict."""
@abstractmethod
def validate(self, name: str) -> None:
"""Validate config. Raise ValueError if invalid."""
@abstractmethod
def get_required_packages(self) -> list[str]:
"""Return pip packages needed."""
@abstractmethod
def get_env_vars_for_kernel(self) -> dict[str, str]:
"""Return env vars to inject into kernel."""
@abstractmethod
def to_python_prelude(self) -> str:
"""Generate Python code for connection + helpers."""
# --- Utilities ---
def substitute_env_vars(value: Any) -> tuple[Any, str | None]:
"""Substitute ${VAR_NAME} with environment variable value."""
if not isinstance(value, str):
return value, None
match = re.match(r"^\$\{([^}]+)\}$", value)
if match:
env_var_name = match.group(1)
env_value = os.environ.get(env_var_name)
return (env_value if env_value else value), env_var_name
return value, None
# --- Registry ---
_CONNECTOR_REGISTRY: dict[str, type[DatabaseConnector]] = {}
T = TypeVar("T", bound="DatabaseConnector")
def register_connector(cls: type[T]) -> type[T]:
_CONNECTOR_REGISTRY[cls.connector_type()] = cls
return cls
def get_connector_class(connector_type: str) -> type[DatabaseConnector]:
if connector_type not in _CONNECTOR_REGISTRY:
available = ", ".join(sorted(_CONNECTOR_REGISTRY.keys()))
raise ValueError(
f"Unknown connector type: {connector_type!r}. Available: {available}"
)
return _CONNECTOR_REGISTRY[connector_type]
def create_connector(data: dict[str, Any]) -> DatabaseConnector:
connector_type = data.get("type", "snowflake")
cls = get_connector_class(connector_type)
return cls.from_dict(data)
def list_connector_types() -> list[str]:
return sorted(_CONNECTOR_REGISTRY.keys())
# --- Snowflake Connector ---
@register_connector
@dataclass
class SnowflakeConnector(DatabaseConnector):
account: str = ""
user: str = ""
auth_type: str = "password"
password: str = ""
private_key_path: str = ""
private_key_passphrase: str = ""
private_key: str = ""
warehouse: str = ""
role: str = ""
schema: str = ""
databases: list[str] = field(default_factory=list)
client_session_keep_alive: bool = False
password_env_var: str | None = None
private_key_env_var: str | None = None
private_key_passphrase_env_var: str | None = None
query_tag: str = ""
@classmethod
def connector_type(cls) -> str:
return "snowflake"
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SnowflakeConnector":
account, _ = substitute_env_vars(data.get("account", ""))
user, _ = substitute_env_vars(data.get("user", ""))
password, pw_env = substitute_env_vars(data.get("password", ""))
private_key, pk_env = substitute_env_vars(data.get("private_key", ""))
passphrase, pp_env = substitute_env_vars(data.get("private_key_passphrase", ""))
return cls(
account=account,
user=user,
auth_type=data.get("auth_type", "password"),
password=password,
private_key_path=data.get("private_key_path", ""),
private_key_passphrase=passphrase,
private_key=private_key,
warehouse=data.get("warehouse", ""),
role=data.get("role", ""),
schema=data.get("schema", ""),
databases=data.get("databases", []),
client_session_keep_alive=data.get("client_session_keep_alive", False),
password_env_var=pw_env,
private_key_env_var=pk_env,
private_key_passphrase_env_var=pp_env,
query_tag=data.get("query_tag", ""),
)
def validate(self, name: str) -> None:
if not self.account or self.account.startswith("${"):
raise ValueError(f"warehouse '{name}': account required")
if not self.user or self.user.startswith("${"):
raise ValueError(f"warehouse '{name}': user required")
if self.auth_type == "password":
if not self.password or self.password.startswith("${"):
raise ValueError(f"warehouse '{name}': password required")
elif self.auth_type == "private_key":
if not self.private_key_path and not self.private_key:
raise ValueError(f"warehouse '{name}': private_key required")
if len(self.query_tag) > 2000:
raise ValueError(
f"warehouse '{name}': query_tag exceeds Snowflake's 2000 character limit"
)
def get_required_packages(self) -> list[str]:
pkgs = ["snowflake-connector-python[pandas]"]
if self.auth_type == "private_key":
pkgs.append("cryptography")
return pkgs
def get_env_vars_for_kernel(self) -> dict[str, str]:
env_vars = {}
if self.password_env_var and self.password:
env_vars[self.password_env_var] = self.password
if self.private_key_env_var and self.private_key:
env_vars[self.private_key_env_var] = self.private_key
if self.private_key_passphrase_env_var and self.private_key_passphrase:
env_vars[self.private_key_passphrase_env_var] = self.private_key_passphrase
return env_vars
def to_python_prelude(self) -> str:
from templates import (
HELPERS_CODE,
PRIVATE_KEY_CONTENT_TEMPLATE,
PRIVATE_KEY_FILE_TEMPLATE,
)
sections = []
# Imports
sections.append("""import snowflake.connector
import polars as pl
import pandas as pd
import os""")
# Private key loader (if needed)
if self.auth_type == "private_key":
if self.private_key_passphrase_env_var:
passphrase_code = f"os.environ.get({self.private_key_passphrase_env_var!r}, '').encode() or None"
elif self.private_key_passphrase:
passphrase_code = f"{self.private_key_passphrase!r}.encode()"
else:
passphrase_code = "None"
if self.private_key_path:
sections.append(
PRIVATE_KEY_FILE_TEMPLATE.substitute(
KEY_PATH=repr(self.private_key_path),
PASSPHRASE_CODE=passphrase_code,
)
)
else:
key_code = (
f"os.environ.get({self.private_key_env_var!r})"
if self.private_key_env_var
else repr(self.private_key)
)
sections.append(
PRIVATE_KEY_CONTENT_TEMPLATE.substitute(
KEY_CODE=key_code,
PASSPHRASE_CODE=passphrase_code,
)
)
# Connection
lines = ["_conn = snowflake.connector.connect("]
lines.append(f" account={self.account!r},")
lines.append(f" user={self.user!r},")
if self.auth_type == "password":
if self.password_env_var:
lines.append(f" password=os.environ.get({self.password_env_var!r}),")
else:
lines.append(f" password={self.password!r},")
elif self.auth_type == "private_key":
lines.append(" private_key=_load_private_key(),")
if self.warehouse:
lines.append(f" warehouse={self.warehouse!r},")
if self.role:
lines.append(f" role={self.role!r},")
if self.databases:
lines.append(f" database={self.databases[0]!r},")
if self.query_tag:
lines.append(f" session_parameters={{'QUERY_TAG': {self.query_tag!r}}},")
lines.append(f" client_session_keep_alive={self.client_session_keep_alive},")
lines.append(")")
sections.append("\n".join(lines))
# Helper functions
helpers_code = HELPERS_CODE
if "def " in helpers_code:
helpers_code = "def " + helpers_code.split("def ", 1)[1]
sections.append(helpers_code.strip())
# Status output
status_lines = [
'print("Snowflake connection established")',
'print(f" Account: {_conn.account}")',
'print(f" User: {_conn.user}")',
]
if self.warehouse:
status_lines.append(f'print(f" Warehouse: {self.warehouse}")')
if self.role:
status_lines.append(f'print(f" Role: {self.role}")')
if self.databases:
status_lines.append(f'print(f" Database: {self.databases[0]}")')
if self.query_tag:
status_lines.append(f'print(f" Query Tag: {self.query_tag}")')
status_lines.append(
'print("\\nAvailable: run_sql(query) -> polars, run_sql_pandas(query) -> pandas")'
)
sections.append("\n".join(status_lines))
return "\n\n".join(sections)
# --- PostgreSQL Connector ---
@register_connector
@dataclass
class PostgresConnector(DatabaseConnector):
host: str = ""
port: int = 5432
user: str = ""
password: str = ""
database: str = ""
sslmode: str = ""
databases: list[str] = field(default_factory=list)
password_env_var: str | None = None
application_name: str = ""
@classmethod
def connector_type(cls) -> str:
return "postgres"
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PostgresConnector":
host, _ = substitute_env_vars(data.get("host", ""))
user, _ = substitute_env_vars(data.get("user", ""))
password, pw_env = substitute_env_vars(data.get("password", ""))
database, _ = substitute_env_vars(data.get("database", ""))
return cls(
host=host,
port=data.get("port", 5432),
user=user,
password=password,
database=database,
sslmode=data.get("sslmode", ""),
databases=data.get("databases", [database] if database else []),
password_env_var=pw_env,
application_name=data.get("application_name", ""),
)
def validate(self, name: str) -> None:
if not self.host or self.host.startswith("${"):
raise ValueError(f"warehouse '{name}': host required for postgres")
if not self.user or self.user.startswith("${"):
raise ValueError(f"warehouse '{name}': user required for postgres")
if not self.database or self.database.startswith("${"):
raise ValueError(f"warehouse '{name}': database required for postgres")
def get_required_packages(self) -> list[str]:
return ["psycopg[binary,pool]"]
def get_env_vars_for_kernel(self) -> dict[str, str]:
env_vars = {}
if self.password_env_var and self.password:
env_vars[self.password_env_var] = self.password
return env_vars
def to_python_prelude(self) -> str:
lines = ["_conn = psycopg.connect("]
lines.append(f" host={self.host!r},")
lines.append(f" port={self.port},")
lines.append(f" user={self.user!r},")
if self.password_env_var:
lines.append(f" password=os.environ.get({self.password_env_var!r}),")
elif self.password:
lines.append(f" password={self.password!r},")
lines.append(f" dbname={self.database!r},")
if self.sslmode:
lines.append(f" sslmode={self.sslmode!r},")
if self.application_name:
lines.append(f" application_name={self.application_name!r},")
lines.append(" autocommit=True,")
lines.append(")")
connection_code = "\n".join(lines)
status_lines = [
'print("PostgreSQL connection established")',
f'print(" Host: {self.host}:{self.port}")',
f'print(" User: {self.user}")',
f'print(" Database: {self.database}")',
]
if self.application_name:
status_lines.append(f'print(" Application: {self.application_name}")')
status_lines += [
'print("\\nAvailable: run_sql(query) -> polars, run_sql_pandas(query) -> pandas")',
]
status_code = "\n".join(status_lines)
return f'''import psycopg
import polars as pl
import pandas as pd
import os
{connection_code}
def run_sql(query: str, limit: int = 100):
"""Execute SQL and return Polars DataFrame."""
with _conn.cursor() as cursor:
cursor.execute(query)
if cursor.description is None:
return pl.DataFrame()
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
result = pl.DataFrame(rows, schema=columns, orient="row")
return result.head(limit) if limit > 0 and len(result) > limit else result
def run_sql_pandas(query: str, limit: int = 100):
"""Execute SQL and return Pandas DataFrame."""
with _conn.cursor() as cursor:
cursor.execute(query)
if cursor.description is None:
return pd.DataFrame()
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
df = pd.DataFrame(rows, columns=columns)
return df.head(limit) if limit > 0 and len(df) > limit else df
{status_code}'''
# --- BigQuery Connector ---
# Google allows international characters in BQ labels, but we restrict to ASCII
# for simplicity. Expand the regex if international support is needed.
_BQ_LABEL_KEY_RE = re.compile(r"^[a-z][a-z0-9_-]{0,62}$")
_BQ_LABEL_VALUE_RE = re.compile(r"^[a-z0-9_-]{0,63}$")
@register_connector
@dataclass
class BigQueryConnector(DatabaseConnector):
project: str = ""
credentials_path: str = ""
location: str = ""
databases: list[str] = field(default_factory=list)
labels: dict[str, str] = field(default_factory=dict)
@classmethod
def connector_type(cls) -> str:
return "bigquery"
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "BigQueryConnector":
project, _ = substitute_env_vars(data.get("project", ""))
credentials_path, _ = substitute_env_vars(data.get("credentials_path", ""))
return cls(
project=project,
credentials_path=credentials_path,
location=data.get("location", ""),
databases=data.get("databases", [project] if project else []),
labels=data.get("labels", {}),
)
def validate(self, name: str) -> None:
if not self.project or self.project.startswith("${"):
raise ValueError(f"warehouse '{name}': project required for bigquery")
if len(self.labels) > 64:
raise ValueError(
f"warehouse '{name}': BigQuery supports at most 64 labels, got {len(self.labels)}"
)
for k, v in self.labels.items():
if not isinstance(k, str) or not _BQ_LABEL_KEY_RE.match(k):
raise ValueError(
f"warehouse '{name}': invalid BigQuery label key {k!r} "
"(must match [a-z][a-z0-9_-]{0,62})"
)
if not isinstance(v, str):
raise ValueError(
f"warehouse '{name}': label value for {k!r} must be a string, got {type(v).__name__}"
)
if not _BQ_LABEL_VALUE_RE.match(v):
raise ValueError(
f"warehouse '{name}': invalid BigQuery label value {v!r} for key {k!r} "
"(must match [a-z0-9_-]{0,63})"
)
def get_required_packages(self) -> list[str]:
return ["google-cloud-bigquery[pandas,pyarrow]", "db-dtypes"]
def get_env_vars_for_kernel(self) -> dict[str, str]:
env_vars = {}
if self.credentials_path:
env_vars["GOOGLE_APPLICATION_CREDENTIALS"] = self.credentials_path
return env_vars
def to_python_prelude(self) -> str:
if self.credentials_path:
conn_code = f"""from google.oauth2 import service_account
_credentials = service_account.Credentials.from_service_account_file({self.credentials_path!r})
_client = bigquery.Client(project={self.project!r}, credentials=_credentials)"""
else:
conn_code = f"_client = bigquery.Client(project={self.project!r})"
# Build QueryJobConfig arguments
job_config_args = []
if self.labels:
job_config_args.append(f"labels={self.labels!r}")
job_config_str = ", ".join(job_config_args)
# Build _client.query() extra kwargs
query_extra_args = ""
if self.location:
query_extra_args = f", location={self.location!r}"
auth_type = (
"Service Account"
if self.credentials_path
else "Application Default Credentials"
)
status_lines = [
'print("BigQuery client initialized")',
f'print(f" Project: {self.project}")',
]
if self.location:
status_lines.append(f'print(f" Location: {self.location}")')
status_lines.append(f'print(" Auth: {auth_type}")')
if self.labels:
status_lines.append(f'print(f" Labels: {self.labels!r}")')
status_lines.append(
'print("\\nAvailable: run_sql(query) -> polars, run_sql_pandas(query) -> pandas")'
)
status_code = "\n".join(status_lines)
return f'''from google.cloud import bigquery
import polars as pl
import pandas as pd
import os
{conn_code}
def run_sql(query: str, limit: int = 100):
"""Execute SQL and return Polars DataFrame."""
job_config = bigquery.QueryJobConfig({job_config_str})
query_job = _client.query(query, job_config=job_config{query_extra_args})
df = query_job.to_dataframe()
result = pl.from_pandas(df)
return result.head(limit) if limit > 0 and len(result) > limit else result
def run_sql_pandas(query: str, limit: int = 100):
"""Execute SQL and return Pandas DataFrame."""
job_config = bigquery.QueryJobConfig({job_config_str})
query_job = _client.query(query, job_config=job_config{query_extra_args})
df = query_job.to_dataframe()
return df.head(limit) if limit > 0 and len(df) > limit else df
{status_code}'''
# --- SQLAlchemy Connector ---
class DialectInfo(NamedTuple):
"""Database dialect configuration.
To add a new database:
1. Add an entry to DIALECTS below with (display_name, [packages])
2. Run tests: uv run pytest tests/test_connectors.py -v
"""
display_name: str
packages: list[str]
# Mapping of dialect/driver names to their configuration.
# The dialect is extracted from URLs like "dialect+driver://..." or "dialect://..."
# When a driver is specified (e.g., mysql+pymysql), the driver name is looked up first.
DIALECTS: dict[str, DialectInfo] = {
# PostgreSQL variants
"postgresql": DialectInfo("PostgreSQL", ["psycopg[binary]"]),
"postgres": DialectInfo("PostgreSQL", ["psycopg[binary]"]),
"psycopg": DialectInfo("PostgreSQL", ["psycopg[binary]"]),
"psycopg2": DialectInfo("PostgreSQL", ["psycopg2-binary"]),
"pg8000": DialectInfo("PostgreSQL", ["pg8000"]),
"asyncpg": DialectInfo("PostgreSQL", ["asyncpg"]),
# MySQL variants
"mysql": DialectInfo("MySQL", ["pymysql"]),
"pymysql": DialectInfo("MySQL", ["pymysql"]),
"mysqlconnector": DialectInfo("MySQL", ["mysql-connector-python"]),
"mysqldb": DialectInfo("MySQL", ["mysqlclient"]),
"mariadb": DialectInfo("MariaDB", ["mariadb"]),
# SQLite (built-in, no extra packages)
"sqlite": DialectInfo("SQLite", []),
# Oracle
"oracle": DialectInfo("Oracle", ["oracledb"]),
"oracledb": DialectInfo("Oracle", ["oracledb"]),
# SQL Server
"mssql": DialectInfo("SQL Server", ["pyodbc"]),
"pyodbc": DialectInfo("SQL Server", ["pyodbc"]),
"pymssql": DialectInfo("SQL Server", ["pymssql"]),
# Cloud data warehouses
"redshift": DialectInfo("Redshift", ["redshift_connector"]),
"redshift_connector": DialectInfo("Redshift", ["redshift_connector"]),
"snowflake": DialectInfo(
"Snowflake", ["snowflake-sqlalchemy", "snowflake-connector-python"]
),
"bigquery": DialectInfo("BigQuery", ["sqlalchemy-bigquery"]),
# DuckDB
"duckdb": DialectInfo("DuckDB", ["duckdb", "duckdb-engine"]),
# Other databases
"trino": DialectInfo("Trino", ["trino"]),
"clickhouse": DialectInfo(
"ClickHouse", ["clickhouse-driver", "clickhouse-sqlalchemy"]
),
"cockroachdb": DialectInfo(
"CockroachDB", ["sqlalchemy-cockroachdb", "psycopg[binary]"]
),
"databricks": DialectInfo("Databricks", ["databricks-sql-connector"]),
"teradata": DialectInfo("Teradata", ["teradatasqlalchemy"]),
"vertica": DialectInfo("Vertica", ["vertica-python"]),
"hana": DialectInfo("SAP HANA", ["hdbcli"]),
"db2": DialectInfo("IBM Db2", ["ibm_db_sa"]),
"firebird": DialectInfo("Firebird", ["fdb"]),
"awsathena": DialectInfo("Amazon Athena", ["pyathena"]),
"spanner": DialectInfo("Cloud Spanner", ["sqlalchemy-spanner"]),
}
def _extract_dialect(url: str) -> str | None:
"""Extract dialect name from SQLAlchemy URL.
URLs can be:
- dialect://user:pass@host/db
- dialect+driver://user:pass@host/db
When a driver is specified, returns the driver name (looked up first in DIALECTS).
Falls back to dialect name if driver isn't in DIALECTS.
"""
match = re.match(r"^([a-zA-Z0-9_-]+)(?:\+([a-zA-Z0-9_-]+))?://", url)
if match:
dialect = match.group(1).lower()
driver = match.group(2).lower() if match.group(2) else None
# Prefer driver if specified AND it's in our dialects mapping
# Otherwise fall back to dialect (e.g., postgresql+asyncpg -> asyncpg if known)
if driver and driver in DIALECTS:
return driver
return dialect
return None
@register_connector
@dataclass
class SQLAlchemyConnector(DatabaseConnector):
url: str = ""
databases: list[str] = field(default_factory=list)
pool_size: int = 5
echo: bool = False
url_env_var: str | None = None
connect_args: dict[str, Any] = field(default_factory=dict)
@classmethod
def connector_type(cls) -> str:
return "sqlalchemy"
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SQLAlchemyConnector":
url, url_env = substitute_env_vars(data.get("url", ""))
return cls(
url=url,
databases=data.get("databases", []),
pool_size=data.get("pool_size", 5),
echo=data.get("echo", False),
url_env_var=url_env,
connect_args=data.get("connect_args", {}),
)
def validate(self, name: str) -> None:
if not self.url or self.url.startswith("${"):
raise ValueError(f"warehouse '{name}': url required for sqlalchemy")
if not self.databases:
raise ValueError(
f"warehouse '{name}': databases list required for sqlalchemy"
)
def get_required_packages(self) -> list[str]:
packages = ["sqlalchemy"]
dialect = _extract_dialect(self.url)
if dialect and dialect in DIALECTS:
packages.extend(DIALECTS[dialect].packages)
return packages
def get_env_vars_for_kernel(self) -> dict[str, str]:
env_vars = {}
if self.url_env_var and self.url:
env_vars[self.url_env_var] = self.url
return env_vars
def to_python_prelude(self) -> str:
if self.url_env_var:
url_code = f"os.environ.get({self.url_env_var!r})"
else:
url_code = repr(self.url)
# Infer DB type for status message
dialect = _extract_dialect(self.url)
db_type = (
DIALECTS[dialect].display_name
if dialect and dialect in DIALECTS
else "Database"
)
databases_str = ", ".join(self.databases)
return f'''from sqlalchemy import create_engine, text
import polars as pl
import pandas as pd
import os
import atexit
_engine = create_engine({url_code}, pool_size={self.pool_size}, echo={self.echo}{f", connect_args={self.connect_args!r}" if self.connect_args else ""})
_conn = _engine.connect()
atexit.register(lambda: (_conn.close(), _engine.dispose()))
def run_sql(query: str, limit: int = 100):
"""Execute SQL and return Polars DataFrame."""
result = _conn.execute(text(query))
if result.returns_rows:
columns = list(result.keys())
rows = result.fetchall()
df = pl.DataFrame(rows, schema=columns, orient="row")
return df.head(limit) if limit > 0 and len(df) > limit else df
return pl.DataFrame()
def run_sql_pandas(query: str, limit: int = 100):
"""Execute SQL and return Pandas DataFrame."""
result = _conn.execute(text(query))
if result.returns_rows:
columns = list(result.keys())
rows = result.fetchall()
df = pd.DataFrame(rows, columns=columns)
return df.head(limit) if limit > 0 and len(df) > limit else df
return pd.DataFrame()
print("{db_type} connection established (via SQLAlchemy)")
print(f" Database(s): {databases_str}")
print("\\nAvailable: run_sql(query) -> polars, run_sql_pandas(query) -> pandas")'''
__all__ = [
"DatabaseConnector",
"substitute_env_vars",
"register_connector",
"get_connector_class",
"create_connector",
"list_connector_types",
"SnowflakeConnector",
"PostgresConnector",
"BigQueryConnector",
"SQLAlchemyConnector",
"DialectInfo",
"DIALECTS",
]
"""Jupyter kernel manager for executing Python code with persistent state."""
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from jupyter_client import KernelManager as JupyterKernelManager
from jupyter_client import BlockingKernelClient
from config import get_kernel_venv_dir, get_kernel_connection_file
DEFAULT_PACKAGES = [
"ipykernel",
"jupyter_client",
"polars",
"pandas",
"numpy",
"matplotlib",
"seaborn",
"pyyaml",
"python-dotenv",
]
@dataclass
class ExecutionResult:
"""Result of code execution in the kernel."""
success: bool
output: str
error: str | None = None
class KernelManager:
"""Manages a Jupyter kernel for Python code execution."""
def __init__(
self,
venv_dir: Path | None = None,
kernel_name: str = "astro-ai-kernel",
packages: list[str] | None = None,
):
self.venv_dir = venv_dir or get_kernel_venv_dir()
self.kernel_name = kernel_name
self.packages = packages or DEFAULT_PACKAGES.copy()
self.connection_file = get_kernel_connection_file()
self._km: JupyterKernelManager | None = None
@property
def python_path(self) -> Path:
if sys.platform == "win32":
return self.venv_dir / "Scripts" / "python.exe"
return self.venv_dir / "bin" / "python"
@property
def is_running(self) -> bool:
if not self.connection_file.exists():
return False
try:
kc = BlockingKernelClient()
kc.load_connection_file(str(self.connection_file))
kc.start_channels()
try:
kc.wait_for_ready(timeout=2)
return True
except Exception:
return False
finally:
kc.stop_channels()
except Exception:
return False
def ensure_environment(self, extra_packages: list[str] | None = None) -> None:
if not shutil.which("uv"):
raise RuntimeError(
"uv is not installed.\n"
"Install with: curl -LsSf https://astral.sh/uv/install.sh | sh"
)
packages = self.packages.copy()
if extra_packages:
packages.extend(extra_packages)
if not self.venv_dir.exists():
print(f"Creating environment at {self.venv_dir}")
subprocess.run(
["uv", "venv", str(self.venv_dir), "--seed"],
check=True,
capture_output=True,
)
print("Installing packages...")
subprocess.run(
["uv", "pip", "install", "--python", str(self.python_path)] + packages,
check=True,
capture_output=True,
)
# Register kernel
try:
subprocess.run(
[
str(self.python_path),
"-m",
"ipykernel",
"install",
"--user",
"--name",
self.kernel_name,
"--display-name",
"Data Analysis Kernel",
],
capture_output=True,
timeout=30,
)
except Exception:
pass
def start(
self,
env_vars: dict[str, str] | None = None,
extra_packages: list[str] | None = None,
) -> None:
if self.is_running:
print("Kernel already running")
return
self.ensure_environment(extra_packages=extra_packages)
print("Starting kernel...")
self._km = JupyterKernelManager(kernel_name=self.kernel_name)
if env_vars:
import os
for key, value in env_vars.items():
os.environ[key] = value
self._km.start_kernel(extra_arguments=["--IPKernelApp.parent_handle=0"])
self.connection_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(self._km.connection_file, self.connection_file)
kc = self._km.client()
kc.start_channels()
try:
kc.wait_for_ready(timeout=10)
except Exception as e:
self.stop()
raise RuntimeError(f"Kernel failed: {e}") from e
finally:
kc.stop_channels()
# Inject idle timeout watchdog into the kernel
self._km.client().execute(
"import threading, time, os, signal\n"
"_idle_timeout = 1800\n" # 30 minutes
"_last_active = [time.time()]\n"
"_orig_execute = get_ipython().run_cell\n"
"def _tracked_execute(*a, **kw):\n"
" _last_active[0] = time.time()\n"
" return _orig_execute(*a, **kw)\n"
"get_ipython().run_cell = _tracked_execute\n"
"def _idle_watchdog():\n"
" while True:\n"
" time.sleep(60)\n"
" if time.time() - _last_active[0] > _idle_timeout:\n"
" os._exit(0)\n"
"_t = threading.Thread(target=_idle_watchdog, daemon=True)\n"
"_t.start()\n",
silent=True,
)
self._km = None
print(f"Kernel started ({self.connection_file})")
def stop(self) -> None:
if not self.connection_file.exists():
print("Kernel not running")
return
try:
kc = BlockingKernelClient()
kc.load_connection_file(str(self.connection_file))
kc.start_channels()
kc.shutdown()
kc.stop_channels()
except Exception:
pass
if self.connection_file.exists():
self.connection_file.unlink()
print('{"message": "Kernel stopped"}')
def execute(self, code: str, timeout: float = 30.0) -> ExecutionResult:
if not self.connection_file.exists():
return ExecutionResult(
False, "", "Kernel not running. Start with: uv run scripts/cli.py start"
)
kc = BlockingKernelClient()
kc.load_connection_file(str(self.connection_file))
kc.start_channels()
try:
kc.wait_for_ready(timeout=5)
except Exception as e:
kc.stop_channels()
return ExecutionResult(False, "", f"Kernel not responding: {e}")
msg_id = kc.execute(code, silent=False, store_history=True)
output_parts: list[str] = []
error_msg: str | None = None
status = "ok"
deadline = time.time() + timeout
done = False
while time.time() < deadline and not done:
try:
msg = kc.get_iopub_msg(timeout=min(1.0, deadline - time.time()))
if msg["parent_header"].get("msg_id") != msg_id:
continue
msg_type = msg["msg_type"]
content = msg["content"]
if msg_type == "stream":
output_parts.append(content["text"])
elif msg_type == "execute_result":
output_parts.append(content["data"].get("text/plain", ""))
elif msg_type == "error":
error_msg = "\n".join(content["traceback"])
status = "error"
elif msg_type == "status" and content["execution_state"] == "idle":
done = True
except Exception:
continue
kc.stop_channels()
if not done:
return ExecutionResult(
False, "".join(output_parts), f"Timeout after {timeout}s"
)
return ExecutionResult(status == "ok", "".join(output_parts), error_msg)
def status(self) -> dict:
info = {
"running": False,
"connection_file": str(self.connection_file),
"responsive": False,
}
if not self.connection_file.exists():
return info
info["running"] = True
try:
kc = BlockingKernelClient()
kc.load_connection_file(str(self.connection_file))
kc.start_channels()
try:
kc.wait_for_ready(timeout=2)
info["responsive"] = True
except Exception:
pass
finally:
kc.stop_channels()
except Exception:
pass
return info
def install_packages(self, packages: list[str]) -> tuple[bool, str]:
"""Install additional packages into the kernel environment.
Args:
packages: List of package specs (e.g., ['plotly>=5.0', 'scipy'])
Returns:
Tuple of (success, message)
"""
if not packages:
return False, "No packages specified"
if not shutil.which("uv"):
return False, "uv is not installed"
try:
result = subprocess.run(
["uv", "pip", "install", "--python", str(self.python_path)] + packages,
capture_output=True,
text=True,
)
if result.returncode == 0:
return True, f"Installed: {', '.join(packages)}"
else:
return False, f"Failed: {result.stderr}"
except Exception as e:
return False, f"Error: {e}"
[project]
name = "analyzing-data-scripts"
version = "0.0.0"
description = "Internal scripts for analyzing-data skill (not a published package)"
requires-python = ">=3.11"
classifiers = [
"Private :: Do Not Upload",
]
[project.optional-dependencies]
test = ["pytest", "sqlalchemy", "polars", "pandas", "pyyaml", "python-dotenv"]
test-integration = [
"pytest",
"sqlalchemy",
"polars",
"pandas",
"pyyaml",
"python-dotenv",
"psycopg[binary]",
"duckdb",
"duckdb-engine",
]
"""Template code injected into Jupyter kernels.
Contains SQL helper functions and private key loaders for Snowflake auth.
"""
from string import Template
# --- SQL Helpers (injected into kernel after connection) ---
# ruff: noqa: F821
HELPERS_CODE = '''\
def run_sql(query: str, limit: int = 100):
"""Execute SQL and return Polars DataFrame."""
cursor = _conn.cursor()
try:
cursor.execute(query)
try:
df = cursor.fetch_pandas_all()
result = pl.from_pandas(df)
except Exception:
rows = cursor.fetchall()
columns = (
[desc[0] for desc in cursor.description] if cursor.description else []
)
result = pl.DataFrame(rows, schema=columns, orient="row")
return result.head(limit) if limit > 0 and len(result) > limit else result
finally:
cursor.close()
def run_sql_pandas(query: str, limit: int = 100):
"""Execute SQL and return Pandas DataFrame."""
cursor = _conn.cursor()
try:
cursor.execute(query)
try:
df = cursor.fetch_pandas_all()
except Exception:
rows = cursor.fetchall()
columns = (
[desc[0] for desc in cursor.description] if cursor.description else []
)
df = pd.DataFrame(rows, columns=columns)
return df.head(limit) if limit > 0 and len(df) > limit else df
finally:
cursor.close()
'''
# --- Private Key Templates (for Snowflake auth) ---
PRIVATE_KEY_CONTENT_TEMPLATE = Template(
"""
def _load_private_key():
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
key_content = $KEY_CODE
p_key = serialization.load_pem_private_key(
key_content.encode(), password=$PASSPHRASE_CODE, backend=default_backend()
)
return p_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
"""
)
PRIVATE_KEY_FILE_TEMPLATE = Template(
"""
def _load_private_key():
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from pathlib import Path
with open(Path($KEY_PATH).expanduser(), "rb") as f:
p_key = serialization.load_pem_private_key(
f.read(), password=$PASSPHRASE_CODE, backend=default_backend()
)
return p_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
"""
)
# Tests for analyzing-data skill lib modules
"""Pytest configuration for analyzing-data skill tests."""
import sys
from pathlib import Path
# Add the scripts directory to the Python path for lib imports
scripts_dir = Path(__file__).parent.parent
sys.path.insert(0, str(scripts_dir))
"""Integration tests for database connectors."""
"""Fixtures for integration tests."""
import os
import tempfile
import pytest
@pytest.fixture
def postgres_config():
"""PostgreSQL connection config from environment or skip."""
host = os.environ.get("TEST_POSTGRES_HOST", "localhost")
port = os.environ.get("TEST_POSTGRES_PORT", "5432")
user = os.environ.get("TEST_POSTGRES_USER", "test")
password = os.environ.get("TEST_POSTGRES_PASSWORD", "test")
database = os.environ.get("TEST_POSTGRES_DB", "testdb")
# Check if we can connect
try:
import psycopg
conn = psycopg.connect(
host=host,
port=int(port),
user=user,
password=password,
dbname=database,
connect_timeout=5,
)
conn.close()
except Exception as e:
pytest.skip(f"PostgreSQL not available: {e}")
return {
"host": host,
"port": int(port),
"user": user,
"password": password,
"database": database,
}
@pytest.fixture
def duckdb_path():
"""Temporary DuckDB database file."""
try:
import duckdb # noqa: F401
except ImportError:
pytest.skip("duckdb not installed")
with tempfile.TemporaryDirectory() as tmpdir:
yield f"{tmpdir}/test.duckdb"
@pytest.fixture
def sqlite_path():
"""Temporary SQLite database file."""
with tempfile.TemporaryDirectory() as tmpdir:
yield f"{tmpdir}/test.db"
"""End-to-end tests for DuckDB via SQLAlchemy connector."""
import pytest
from connectors import SQLAlchemyConnector
class TestDuckDBEndToEnd:
"""Integration tests for DuckDB via SQLAlchemy connector."""
def test_connection_and_query(self, duckdb_path):
"""Test full flow: connect, create table, insert, query."""
conn = SQLAlchemyConnector(
url=f"duckdb:///{duckdb_path}",
databases=["main"],
)
conn.validate("test")
# Verify package detection
pkgs = conn.get_required_packages()
assert "duckdb" in pkgs
assert "duckdb-engine" in pkgs
# Generate and execute prelude
prelude = conn.to_python_prelude()
local_vars: dict = {}
exec(prelude, local_vars)
run_sql = local_vars["run_sql"]
run_sql_pandas = local_vars["run_sql_pandas"]
_conn = local_vars["_conn"]
text = local_vars["text"]
try:
# Create test table
_conn.execute(
text("""
CREATE TABLE integration_test (
id INTEGER PRIMARY KEY,
name VARCHAR,
value DECIMAL(10, 2)
)
""")
)
_conn.execute(
text("""
INSERT INTO integration_test VALUES
(1, 'alice', 10.50),
(2, 'bob', 20.75),
(3, 'charlie', 30.00)
""")
)
_conn.commit()
# Test run_sql returns Polars
result = run_sql("SELECT * FROM integration_test ORDER BY id")
assert len(result) == 3
assert "polars" in str(type(result)).lower()
assert result["name"].to_list() == ["alice", "bob", "charlie"]
# Test run_sql_pandas returns Pandas
result_pd = run_sql_pandas("SELECT * FROM integration_test ORDER BY id")
assert len(result_pd) == 3
assert "dataframe" in str(type(result_pd)).lower()
# Test aggregation
result = run_sql("SELECT SUM(value) as total FROM integration_test")
total = float(result["total"][0])
assert total == pytest.approx(61.25)
# Test limit parameter
result = run_sql("SELECT * FROM integration_test", limit=2)
assert len(result) == 2
# Test empty result
result = run_sql("SELECT * FROM integration_test WHERE id = -1")
assert len(result) == 0
# DuckDB-specific: test COPY export (parquet support)
result = run_sql("SELECT COUNT(*) as cnt FROM integration_test")
assert int(result["cnt"][0]) == 3
finally:
_conn.close()
def test_in_memory_database(self):
"""Test DuckDB in-memory mode."""
try:
import duckdb # noqa: F401
except ImportError:
pytest.skip("duckdb not installed")
conn = SQLAlchemyConnector(
url="duckdb:///:memory:",
databases=["memory"],
)
conn.validate("test")
prelude = conn.to_python_prelude()
local_vars: dict = {}
exec(prelude, local_vars)
run_sql = local_vars["run_sql"]
_conn = local_vars["_conn"]
text = local_vars["text"]
try:
_conn.execute(text("CREATE TABLE test (id INT)"))
_conn.execute(text("INSERT INTO test VALUES (1), (2), (3)"))
_conn.commit()
result = run_sql("SELECT COUNT(*) as cnt FROM test")
assert int(result["cnt"][0]) == 3
finally:
_conn.close()
"""End-to-end tests for PostgreSQL connector."""
import pytest
from connectors import PostgresConnector
class TestPostgresEndToEnd:
"""Integration tests for PostgreSQL connector with real database."""
def test_connection_and_query(self, postgres_config):
"""Test full flow: connect, create table, insert, query."""
conn = PostgresConnector(
host=postgres_config["host"],
port=postgres_config["port"],
user=postgres_config["user"],
password=postgres_config["password"],
database=postgres_config["database"],
databases=[postgres_config["database"]],
)
conn.validate("test")
# Generate and execute prelude
prelude = conn.to_python_prelude()
local_vars: dict = {}
exec(prelude, local_vars)
run_sql = local_vars["run_sql"]
run_sql_pandas = local_vars["run_sql_pandas"]
_conn = local_vars["_conn"]
try:
# Create test table
with _conn.cursor() as cursor:
cursor.execute("DROP TABLE IF EXISTS integration_test")
cursor.execute("""
CREATE TABLE integration_test (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
value DECIMAL(10, 2)
)
""")
cursor.execute("""
INSERT INTO integration_test (name, value)
VALUES ('alice', 10.50), ('bob', 20.75), ('charlie', 30.00)
""")
# Test run_sql returns Polars
result = run_sql("SELECT * FROM integration_test ORDER BY id")
assert len(result) == 3
assert "polars" in str(type(result)).lower()
assert result["name"].to_list() == ["alice", "bob", "charlie"]
# Test run_sql_pandas returns Pandas
result_pd = run_sql_pandas("SELECT * FROM integration_test ORDER BY id")
assert len(result_pd) == 3
assert "dataframe" in str(type(result_pd)).lower()
# Test aggregation
result = run_sql("SELECT SUM(value) as total FROM integration_test")
assert float(result["total"][0]) == pytest.approx(61.25)
# Test limit parameter
result = run_sql("SELECT * FROM integration_test", limit=2)
assert len(result) == 2
# Test empty result
result = run_sql("SELECT * FROM integration_test WHERE id = -1")
assert len(result) == 0
finally:
# Cleanup
with _conn.cursor() as cursor:
cursor.execute("DROP TABLE IF EXISTS integration_test")
_conn.close()
def test_prelude_with_env_var_password(self, postgres_config, monkeypatch):
"""Test that password from env var works correctly."""
monkeypatch.setenv("TEST_PG_PASSWORD", postgres_config["password"])
conn = PostgresConnector.from_dict(
{
"host": postgres_config["host"],
"port": postgres_config["port"],
"user": postgres_config["user"],
"password": "${TEST_PG_PASSWORD}",
"database": postgres_config["database"],
}
)
prelude = conn.to_python_prelude()
assert "os.environ.get" in prelude
assert "TEST_PG_PASSWORD" in prelude
# Execute with env var injected
env_vars = conn.get_env_vars_for_kernel()
local_vars: dict = {}
for key, value in env_vars.items():
monkeypatch.setenv(key, value)
exec(prelude, local_vars)
result = local_vars["run_sql"]("SELECT 1 as test")
assert len(result) == 1
local_vars["_conn"].close()
"""End-to-end tests for SQLite via SQLAlchemy connector."""
from connectors import SQLAlchemyConnector
class TestSQLiteEndToEnd:
"""Integration tests for SQLite via SQLAlchemy connector."""
def test_connection_and_query(self, sqlite_path):
"""Test full flow: connect, create table, insert, query."""
conn = SQLAlchemyConnector(
url=f"sqlite:///{sqlite_path}",
databases=["main"],
)
conn.validate("test")
# SQLite doesn't need extra packages
pkgs = conn.get_required_packages()
assert pkgs == ["sqlalchemy"]
# Generate and execute prelude
prelude = conn.to_python_prelude()
local_vars: dict = {}
exec(prelude, local_vars)
run_sql = local_vars["run_sql"]
run_sql_pandas = local_vars["run_sql_pandas"]
_conn = local_vars["_conn"]
text = local_vars["text"]
try:
# Create test table
_conn.execute(
text("""
CREATE TABLE integration_test (
id INTEGER PRIMARY KEY,
name TEXT,
value REAL
)
""")
)
_conn.execute(
text("""
INSERT INTO integration_test (name, value)
VALUES ('alice', 10.50), ('bob', 20.75), ('charlie', 30.00)
""")
)
_conn.commit()
# Test run_sql returns Polars
result = run_sql("SELECT * FROM integration_test ORDER BY id")
assert len(result) == 3
assert "polars" in str(type(result)).lower()
assert result["name"].to_list() == ["alice", "bob", "charlie"]
# Test run_sql_pandas returns Pandas
result_pd = run_sql_pandas("SELECT * FROM integration_test ORDER BY id")
assert len(result_pd) == 3
assert "dataframe" in str(type(result_pd)).lower()
# Test aggregation
result = run_sql("SELECT SUM(value) as total FROM integration_test")
assert float(result["total"][0]) == 61.25
# Test limit parameter
result = run_sql("SELECT * FROM integration_test", limit=2)
assert len(result) == 2
# Test empty result
result = run_sql("SELECT * FROM integration_test WHERE id = -1")
assert len(result) == 0
finally:
_conn.close()
def test_in_memory_database(self):
"""Test SQLite in-memory mode."""
conn = SQLAlchemyConnector(
url="sqlite:///:memory:",
databases=["memory"],
)
conn.validate("test")
prelude = conn.to_python_prelude()
local_vars: dict = {}
exec(prelude, local_vars)
run_sql = local_vars["run_sql"]
_conn = local_vars["_conn"]
text = local_vars["text"]
try:
_conn.execute(text("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)"))
_conn.execute(text("INSERT INTO test (name) VALUES ('a'), ('b'), ('c')"))
_conn.commit()
result = run_sql("SELECT COUNT(*) as cnt FROM test")
assert int(result["cnt"][0]) == 3
finally:
_conn.close()
def test_data_types(self, sqlite_path):
"""Test various SQLite data types are handled correctly."""
conn = SQLAlchemyConnector(
url=f"sqlite:///{sqlite_path}",
databases=["main"],
)
prelude = conn.to_python_prelude()
local_vars: dict = {}
exec(prelude, local_vars)
run_sql = local_vars["run_sql"]
_conn = local_vars["_conn"]
text = local_vars["text"]
try:
_conn.execute(
text("""
CREATE TABLE types_test (
int_col INTEGER,
real_col REAL,
text_col TEXT,
blob_col BLOB
)
""")
)
_conn.execute(
text("""
INSERT INTO types_test VALUES (42, 3.14, 'hello', X'DEADBEEF')
""")
)
_conn.commit()
result = run_sql("SELECT int_col, real_col, text_col FROM types_test")
assert int(result["int_col"][0]) == 42
assert float(result["real_col"][0]) == 3.14
assert result["text_col"][0] == "hello"
finally:
_conn.close()
"""Tests for cache.py - concept, pattern, and table caching."""
from pathlib import Path
from unittest import mock
import pytest
# Mock CACHE_DIR before importing cache module
@pytest.fixture(autouse=True)
def mock_cache_dir(tmp_path):
"""Use a temporary directory for all cache tests."""
with mock.patch("cache.CACHE_DIR", tmp_path):
yield tmp_path
class TestConceptCache:
"""Tests for concept caching functions."""
def test_lookup_concept_not_found(self, mock_cache_dir):
import cache
result = cache.lookup_concept("nonexistent")
assert result is None
def test_learn_and_lookup_concept(self, mock_cache_dir):
import cache
# Learn a concept
result = cache.learn_concept(
concept="customers",
table="HQ.MART.CUSTOMERS",
key_column="CUST_ID",
date_column="CREATED_AT",
)
assert result["table"] == "HQ.MART.CUSTOMERS"
assert result["key_column"] == "CUST_ID"
assert result["date_column"] == "CREATED_AT"
assert "learned_at" in result
# Look it up
found = cache.lookup_concept("customers")
assert found is not None
assert found["table"] == "HQ.MART.CUSTOMERS"
def test_concept_case_insensitive(self, mock_cache_dir):
import cache
cache.learn_concept("Customers", "HQ.MART.CUSTOMERS")
assert cache.lookup_concept("customers") is not None
assert cache.lookup_concept("CUSTOMERS") is not None
def test_list_concepts(self, mock_cache_dir):
import cache
cache.learn_concept("customers", "TABLE1")
cache.learn_concept("orders", "TABLE2")
concepts = cache.list_concepts()
assert len(concepts) == 2
assert "customers" in concepts
assert "orders" in concepts
class TestPatternCache:
"""Tests for pattern caching functions."""
def test_lookup_pattern_no_match(self, mock_cache_dir):
import cache
result = cache.lookup_pattern("some random question")
assert result == []
def test_learn_and_lookup_pattern(self, mock_cache_dir):
import cache
cache.learn_pattern(
name="customer_count",
question_types=["how many customers", "count customers"],
strategy=["Query CUSTOMERS table", "Use COUNT(*)"],
tables_used=["HQ.MART.CUSTOMERS"],
gotchas=["Filter by active status"],
)
# Should match
matches = cache.lookup_pattern("how many customers do we have")
assert len(matches) == 1
assert matches[0]["name"] == "customer_count"
# Should also match variant
matches = cache.lookup_pattern("count customers please")
assert len(matches) == 1
def test_record_pattern_outcome(self, mock_cache_dir):
import cache
cache.learn_pattern(
name="test_pattern",
question_types=["test"],
strategy=["step1"],
tables_used=["TABLE"],
gotchas=[],
)
# Initial counts
patterns = cache.list_patterns()
assert patterns["test_pattern"]["success_count"] == 1
assert patterns["test_pattern"]["failure_count"] == 0
# Record success
cache.record_pattern_outcome("test_pattern", success=True)
patterns = cache.list_patterns()
assert patterns["test_pattern"]["success_count"] == 2
# Record failure
cache.record_pattern_outcome("test_pattern", success=False)
patterns = cache.list_patterns()
assert patterns["test_pattern"]["failure_count"] == 1
def test_delete_pattern(self, mock_cache_dir):
import cache
cache.learn_pattern(
name="to_delete",
question_types=["test"],
strategy=["step1"],
tables_used=["TABLE"],
gotchas=[],
)
assert cache.delete_pattern("to_delete") is True
assert cache.delete_pattern("to_delete") is False # Already deleted
assert "to_delete" not in cache.list_patterns()
class TestTableCache:
"""Tests for table schema caching."""
def test_get_table_not_found(self, mock_cache_dir):
import cache
result = cache.get_table("NONEXISTENT.TABLE")
assert result is None
def test_set_and_get_table(self, mock_cache_dir):
import cache
columns = [
{"name": "ID", "type": "INT"},
{"name": "NAME", "type": "VARCHAR"},
]
result = cache.set_table(
full_name="DB.SCHEMA.TABLE",
columns=columns,
row_count=1000,
comment="Test table",
)
assert result["full_name"] == "DB.SCHEMA.TABLE"
assert result["columns"] == columns
assert result["row_count"] == 1000
# Retrieve it
found = cache.get_table("DB.SCHEMA.TABLE")
assert found is not None
assert found["row_count"] == 1000
def test_table_name_case_insensitive(self, mock_cache_dir):
import cache
cache.set_table("db.schema.table", [])
assert cache.get_table("DB.SCHEMA.TABLE") is not None
def test_delete_table(self, mock_cache_dir):
import cache
cache.set_table("DB.SCHEMA.TABLE", [])
assert cache.delete_table("DB.SCHEMA.TABLE") is True
assert cache.delete_table("DB.SCHEMA.TABLE") is False
assert cache.get_table("DB.SCHEMA.TABLE") is None
class TestCacheManagement:
"""Tests for cache statistics and clearing."""
def test_cache_stats(self, mock_cache_dir):
import cache
cache.learn_concept("c1", "T1")
cache.learn_concept("c2", "T2")
cache.learn_pattern("p1", ["q"], ["s"], ["t"], [])
stats = cache.cache_stats()
assert stats["concepts_count"] == 2
assert stats["patterns_count"] == 1
assert stats["cache_dir"] == str(mock_cache_dir)
def test_clear_cache_all(self, mock_cache_dir):
import cache
cache.learn_concept("c1", "T1")
cache.learn_pattern("p1", ["q"], ["s"], ["t"], [])
result = cache.clear_cache("all")
assert result["concepts_cleared"] == 1
assert result["patterns_cleared"] == 1
assert cache.list_concepts() == {}
assert cache.list_patterns() == {}
def test_clear_cache_concepts_only(self, mock_cache_dir):
import cache
cache.learn_concept("c1", "T1")
cache.learn_pattern("p1", ["q"], ["s"], ["t"], [])
result = cache.clear_cache("concepts")
assert result["concepts_cleared"] == 1
assert result["patterns_cleared"] == 0
assert cache.list_concepts() == {}
assert len(cache.list_patterns()) == 1
class TestBulkImport:
"""Tests for loading concepts from warehouse.md."""
def test_load_concepts_from_warehouse_md(self, mock_cache_dir, tmp_path):
import cache
# Create a test warehouse.md
warehouse_md = tmp_path / "warehouse.md"
warehouse_md.write_text("""
# Warehouse Reference
| Concept | Table | Key Column | Date Column |
|---------|-------|------------|-------------|
| customers | HQ.MART.CUSTOMERS | CUST_ID | CREATED_AT |
| orders | HQ.MART.ORDERS | ORDER_ID | ORDER_DATE |
| invalid | no_dots | - | - |
""")
count = cache.load_concepts_from_warehouse_md(warehouse_md)
assert count == 2 # 'invalid' should be skipped (no dots)
concepts = cache.list_concepts()
assert "customers" in concepts
assert concepts["customers"]["table"] == "HQ.MART.CUSTOMERS"
assert "orders" in concepts
def test_load_concepts_file_not_found(self, mock_cache_dir):
import cache
count = cache.load_concepts_from_warehouse_md(Path("/nonexistent/file.md"))
assert count == 0
"""Tests for config.py - path utilities."""
from pathlib import Path
from unittest.mock import patch
import warnings
class TestConfigPaths:
"""Tests for configuration path functions."""
def test_get_kernel_venv_dir_new_path(self):
"""Test kernel venv dir returns new path when no legacy exists."""
import config as config_module
config_module._legacy_warning_shown = False
with patch.object(Path, "exists", return_value=False):
result = config_module.get_kernel_venv_dir()
assert isinstance(result, Path)
assert result.parts[-3:] == (".astro", "agents", "kernel_venv")
def test_get_kernel_connection_file_new_path(self):
"""Test kernel connection file returns new path when no legacy exists."""
import config as config_module
config_module._legacy_warning_shown = False
with patch.object(Path, "exists", return_value=False):
result = config_module.get_kernel_connection_file()
assert isinstance(result, Path)
assert result.name == "kernel.json"
assert result.parts[-3:-1] == (".astro", "agents")
def test_get_config_dir_new_path(self):
"""Test config dir returns new path when no legacy exists."""
import config as config_module
config_module._legacy_warning_shown = False
with patch.object(Path, "exists", return_value=False):
result = config_module.get_config_dir()
assert isinstance(result, Path)
assert result.parts[-2:] == (".astro", "agents")
class TestLegacyPathFallback:
"""Tests for backward compatibility with legacy path."""
def test_get_config_dir_uses_legacy_when_exists(self):
"""Test that legacy path is used when it exists and new path doesn't."""
import config as config_module
config_module._legacy_warning_shown = False
def mock_exists(self):
# Legacy path exists, new path doesn't
path_str = str(self)
if ".astro/ai/config" in path_str:
return True
if ".astro/agents" in path_str:
return False
return False
with patch.object(Path, "exists", mock_exists):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config_module.get_config_dir()
assert result.parts[-3:] == (".astro", "ai", "config")
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "Deprecated config path" in str(w[0].message)
def test_get_config_dir_prefers_new_path(self):
"""Test that new path is used when both exist."""
import config as config_module
config_module._legacy_warning_shown = False
def mock_exists(self):
# Both paths exist - should prefer new path
path_str = str(self)
if ".astro/ai/config" in path_str:
return True
if ".astro/agents" in path_str:
return True
return False
with patch.object(Path, "exists", mock_exists):
result = config_module.get_config_dir()
# New path should be preferred when both exist
assert result.parts[-2:] == (".astro", "agents")
def test_legacy_warning_shown_once(self):
"""Test that deprecation warning is only shown once."""
import config as config_module
config_module._legacy_warning_shown = False
def mock_exists(self):
path_str = str(self)
if ".astro/ai/config" in path_str:
return True
if ".astro/agents" in path_str:
return False
return False
with patch.object(Path, "exists", mock_exists):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
# Call multiple times
config_module.get_config_dir()
config_module.get_config_dir()
config_module.get_config_dir()
# Should only have one warning
deprecation_warnings = [
x for x in w if issubclass(x.category, DeprecationWarning)
]
assert len(deprecation_warnings) == 1
def test_kernel_paths_use_legacy_parent(self):
"""Test that kernel paths use legacy parent dir when legacy config exists."""
import config as config_module
config_module._legacy_warning_shown = False
def mock_exists(self):
path_str = str(self)
if ".astro/ai/config" in path_str:
return True
if ".astro/agents" in path_str:
return False
return False
with patch.object(Path, "exists", mock_exists):
with warnings.catch_warnings():
warnings.simplefilter("ignore")
venv_dir = config_module.get_kernel_venv_dir()
conn_file = config_module.get_kernel_connection_file()
# Should be under ~/.astro/ai/ (legacy parent)
assert venv_dir.parts[-3:] == (".astro", "ai", "kernel_venv")
assert conn_file.parts[-3:] == (".astro", "ai", "kernel.json")
"""Tests for connector utilities."""
import pytest
from connectors import substitute_env_vars
class TestSubstituteEnvVars:
"""Tests for substitute_env_vars function."""
@pytest.mark.parametrize(
"value",
[123, None, ["a", "b"], True, {"key": "value"}],
ids=["int", "none", "list", "bool", "dict"],
)
def test_non_string_passthrough(self, value):
result, env_var = substitute_env_vars(value)
assert result == value
assert env_var is None
@pytest.mark.parametrize(
"value",
[
"hello",
"prefix${VAR}",
"${VAR}suffix",
"prefix${VAR}suffix",
"$VAR",
"${VAR",
"${}",
"",
],
ids=[
"plain_string",
"prefix_before_var",
"suffix_after_var",
"var_in_middle",
"dollar_without_braces",
"unclosed_brace",
"empty_var_name",
"empty_string",
],
)
def test_no_substitution(self, value):
"""Values that don't match the exact ${VAR} pattern are unchanged."""
result, env_var = substitute_env_vars(value)
assert result == value
assert env_var is None
def test_substitution_when_env_var_exists(self, monkeypatch):
monkeypatch.setenv("MY_VAR", "my_value")
result, env_var = substitute_env_vars("${MY_VAR}")
assert result == "my_value"
assert env_var == "MY_VAR"
def test_returns_original_when_env_var_missing(self):
result, env_var = substitute_env_vars("${NONEXISTENT_VAR}")
assert result == "${NONEXISTENT_VAR}"
assert env_var == "NONEXISTENT_VAR"
def test_returns_original_when_env_var_empty(self, monkeypatch):
monkeypatch.setenv("EMPTY_VAR", "")
result, env_var = substitute_env_vars("${EMPTY_VAR}")
# Empty string is falsy, so original is returned
assert result == "${EMPTY_VAR}"
assert env_var == "EMPTY_VAR"
@pytest.mark.parametrize(
"var_name",
["MY_VAR_NAME", "VAR123", "A", "VERY_LONG_VARIABLE_NAME_123"],
)
def test_various_valid_var_names(self, monkeypatch, var_name):
monkeypatch.setenv(var_name, "value")
result, env_var = substitute_env_vars(f"${{{var_name}}}")
assert result == "value"
assert env_var == var_name
"""Tests for warehouse configuration."""
import pytest
from connectors import PostgresConnector, SnowflakeConnector
from warehouse import WarehouseConfig
class TestWarehouseConfigLoad:
"""Tests for WarehouseConfig.load()."""
def test_load_valid_single_connector(self, tmp_path):
config_file = tmp_path / "warehouse.yml"
config_file.write_text("""
my_postgres:
type: postgres
host: localhost
user: testuser
password: testpass
database: testdb
""")
config = WarehouseConfig.load(config_file)
assert "my_postgres" in config.connectors
assert isinstance(config.connectors["my_postgres"], PostgresConnector)
assert config.connectors["my_postgres"].host == "localhost"
def test_load_valid_multiple_connectors(self, tmp_path):
config_file = tmp_path / "warehouse.yml"
config_file.write_text("""
snowflake_prod:
type: snowflake
account: myaccount
user: myuser
password: mypass
postgres_analytics:
type: postgres
host: db.example.com
user: analyst
password: secret
database: analytics
""")
config = WarehouseConfig.load(config_file)
assert len(config.connectors) == 2
assert "snowflake_prod" in config.connectors
assert "postgres_analytics" in config.connectors
assert isinstance(config.connectors["snowflake_prod"], SnowflakeConnector)
assert isinstance(config.connectors["postgres_analytics"], PostgresConnector)
def test_load_file_not_found(self, tmp_path):
config_file = tmp_path / "nonexistent.yml"
with pytest.raises(FileNotFoundError, match="Config not found"):
WarehouseConfig.load(config_file)
def test_load_empty_yaml(self, tmp_path):
config_file = tmp_path / "warehouse.yml"
config_file.write_text("")
with pytest.raises(ValueError, match="No configs"):
WarehouseConfig.load(config_file)
def test_load_yaml_with_only_comments(self, tmp_path):
config_file = tmp_path / "warehouse.yml"
config_file.write_text("# Just a comment\n# Another comment")
with pytest.raises(ValueError, match="No configs"):
WarehouseConfig.load(config_file)
def test_load_validates_each_connector(self, tmp_path):
config_file = tmp_path / "warehouse.yml"
# Missing required 'host' for postgres
config_file.write_text("""
bad_postgres:
type: postgres
user: testuser
password: testpass
database: testdb
""")
with pytest.raises(ValueError, match="host required"):
WarehouseConfig.load(config_file)
def test_load_unknown_connector_type(self, tmp_path):
config_file = tmp_path / "warehouse.yml"
config_file.write_text("""
unknown:
type: mongodb
host: localhost
""")
with pytest.raises(ValueError, match="Unknown connector type"):
WarehouseConfig.load(config_file)
def test_load_with_env_var_substitution(self, tmp_path, monkeypatch):
monkeypatch.setenv("TEST_DB_PASSWORD", "secretpassword")
config_file = tmp_path / "warehouse.yml"
config_file.write_text("""
my_postgres:
type: postgres
host: localhost
user: testuser
password: ${TEST_DB_PASSWORD}
database: testdb
""")
config = WarehouseConfig.load(config_file)
connector = config.connectors["my_postgres"]
assert isinstance(connector, PostgresConnector)
assert connector.password == "secretpassword"
class TestWarehouseConfigGetDefault:
"""Tests for WarehouseConfig.get_default()."""
def test_get_default_returns_first(self, tmp_path):
config_file = tmp_path / "warehouse.yml"
config_file.write_text("""
first_connector:
type: postgres
host: first.example.com
user: u
password: p
database: d
second_connector:
type: postgres
host: second.example.com
user: u
password: p
database: d
""")
config = WarehouseConfig.load(config_file)
name, connector = config.get_default()
assert name == "first_connector"
assert isinstance(connector, PostgresConnector)
assert connector.host == "first.example.com"
def test_get_default_empty_raises(self):
config = WarehouseConfig(connectors={})
with pytest.raises(ValueError, match="No warehouse configs"):
config.get_default()
# ty type checker configuration
# https://docs.astral.sh/ty/
[rules]
# Ignore unresolved imports for third-party libraries
# ty doesn't install dependencies, so these will always fail
unresolved-import = "ignore"
# Ignore unresolved references in template files
# These variables (_conn, pl, pd) are injected at runtime
unresolved-reference = "ignore"
"""Warehouse configuration and database connection management."""
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from dotenv import load_dotenv
from config import get_config_dir
from connectors import DatabaseConnector, create_connector
def get_warehouse_config_path() -> Path:
return get_config_dir() / "warehouse.yml"
def _load_env_file() -> None:
env_path = get_config_dir() / ".env"
if env_path.exists():
load_dotenv(env_path)
if Path(".env").exists():
load_dotenv(".env", override=True)
@dataclass
class WarehouseConfig:
connectors: dict[str, DatabaseConnector] = field(default_factory=dict)
@classmethod
def load(cls, path: Path | None = None) -> "WarehouseConfig":
_load_env_file()
if path is None:
path = get_warehouse_config_path()
if not path.exists():
raise FileNotFoundError(f"Config not found: {path}")
with open(path) as f:
data = yaml.safe_load(f)
if not data:
raise ValueError(f"No configs in {path}")
connectors: dict[str, DatabaseConnector] = {}
for name, config in data.items():
conn = create_connector(config)
conn.validate(name)
connectors[name] = conn
return cls(connectors=connectors)
def get_default(self) -> tuple[str, DatabaseConnector]:
if not self.connectors:
raise ValueError("No warehouse configs")
name = next(iter(self.connectors))
return name, self.connectors[name]
Related skills
FAQ
What does analyzing-data do?
Queries data warehouse and answers business questions about data. Handles questions requiring database/warehouse queries including "who uses X", "how many Y", "show me Z",.
When should I use analyzing-data?
User asks about analyzing data or related SKILL.md workflows.
Is analyzing-data safe to install?
Review the Security Audits panel on this page before installing in production.