chore(skills): add wren-dlt-connector skill v1.0 (#1535)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jax Liu
2026-04-09 17:09:19 +08:00
committed by GitHub
parent 0e457090e4
commit af43a3a34e
9 changed files with 954 additions and 2 deletions
+1
View File
@@ -57,6 +57,7 @@ Once installed, invoke a skill by name in your conversation:
|-------|-------------|
| [wren-usage](wren-usage/SKILL.md) | **Primary skill** — CLI workflow guide: query data via `wren --sql`, gather schema context with `wren memory`, store/recall queries, handle errors |
| [wren-generate-mdl](wren-generate-mdl/SKILL.md) | Generate a Wren MDL project from a live database — schema discovery, type normalization, YAML generation |
| [wren-dlt-connector](wren-dlt-connector/SKILL.md) | Connect SaaS data (HubSpot, Stripe, Salesforce, etc.) via dlt pipelines into DuckDB, then auto-generate a Wren project |
### wren-usage reference files
+21
View File
@@ -57,6 +57,27 @@ Generates a Wren MDL project by exploring a live database using whatever tools a
---
## wren-dlt-connector
**File:** [wren-dlt-connector/SKILL.md](wren-dlt-connector/SKILL.md)
Connects SaaS data (HubSpot, Stripe, Salesforce, GitHub, Slack, etc.) to Wren Engine for SQL analysis. Walks through the full flow: install dlt, pick a SaaS source, set up credentials, run the data pipeline into DuckDB, then auto-generate a Wren semantic project from the loaded data.
### When to use
- Connecting SaaS data sources (HubSpot, Stripe, Salesforce, GitHub, Slack, etc.)
- Importing data from an API via dlt pipelines
- Loading SaaS data into DuckDB for SQL analysis
- Creating a Wren project from an existing dlt-produced DuckDB file
### Dependent skills
| Skill | Purpose |
|-------|---------|
| `wren-generate-mdl` | Generate or regenerate MDL from the DuckDB database |
---
## Installing a skill
```bash
+21
View File
@@ -5,6 +5,27 @@
"repository": "https://github.com/Canner/wren-engine",
"license": "Apache-2.0",
"skills": [
{
"name": "wren-dlt-connector",
"version": "1.0",
"description": "Connect SaaS data (HubSpot, Stripe, Salesforce, GitHub, Slack, etc.) to Wren Engine for SQL analysis via dlt pipelines into DuckDB, then auto-generate a Wren semantic project.",
"tags": [
"wren",
"dlt",
"saas",
"duckdb",
"pipeline",
"hubspot",
"stripe",
"salesforce",
"github",
"slack"
],
"dependencies": [
"wren-generate-mdl"
],
"repository": "https://github.com/Canner/wren-engine/tree/main/skills/wren-dlt-connector"
},
{
"name": "wren-generate-mdl",
"version": "2.1",
+11 -2
View File
@@ -13,7 +13,7 @@ set -euo pipefail
REPO="Canner/wren-engine"
BRANCH="${WREN_SKILLS_BRANCH:-main}"
DEST="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}"
ALL_SKILLS=(wren-generate-mdl wren-usage)
ALL_SKILLS=(wren-dlt-connector wren-generate-mdl wren-usage)
# Parse --force flag and skill list from arguments
FORCE=false
@@ -51,8 +51,17 @@ fi
# Locate index.json for dependency resolution (local or remote)
INDEX_JSON=""
INDEX_JSON_TMP=""
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/index.json" ]; then
INDEX_JSON="$SCRIPT_DIR/index.json"
elif command -v curl &>/dev/null; then
INDEX_JSON_TMP="$(mktemp)"
if curl -fsSL "https://raw.githubusercontent.com/$REPO/$BRANCH/skills/index.json" -o "$INDEX_JSON_TMP" 2>/dev/null; then
INDEX_JSON="$INDEX_JSON_TMP"
else
rm -f "$INDEX_JSON_TMP"
INDEX_JSON_TMP=""
fi
fi
# Expand SELECTED_SKILLS to include dependencies declared in index.json.
@@ -153,7 +162,7 @@ else
echo "Destination: $DEST"
echo ""
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
trap 'rm -rf "$tmpdir"; [ -n "${INDEX_JSON_TMP:-}" ] && rm -f "$INDEX_JSON_TMP"' EXIT
extract_paths=()
for skill in "${SELECTED_SKILLS[@]}"; do
+1
View File
@@ -1,4 +1,5 @@
{
"wren-dlt-connector": "1.0",
"wren-generate-mdl": "2.1",
"wren-usage": "2.1"
}
+269
View File
@@ -0,0 +1,269 @@
---
name: wren-dlt-connector
description: "Connect SaaS data (HubSpot, Stripe, Salesforce, GitHub, Slack, etc.) to Wren Engine for SQL analysis. Guides the user through the full flow: install dlt, pick a SaaS source, set up credentials, run the data pipeline into DuckDB, then auto-generate a Wren semantic project from the loaded data. Use this skill whenever the user mentions: connecting SaaS data, importing data from an API, dlt pipelines, loading HubSpot/Stripe/Salesforce/GitHub/Slack data, querying SaaS data with SQL, or setting up a new data source from a REST API. Also trigger when the user already has a dlt-produced DuckDB file and wants to create a Wren project from it."
license: Apache-2.0
metadata:
author: wren-engine
version: "1.0"
---
# wren-dlt-connector
Connect SaaS data to Wren Engine for SQL analysis — from zero to a verified, queryable project in one conversation.
## Who this is for
Data analysts who know SQL and some Python, but may not have used dlt or Wren before. Explain concepts briefly when they first appear, but don't over-explain things a SQL-literate person would already know.
## Overview
This skill walks through a four-phase workflow:
1. **Extract** — Use dlt (data load tool) to pull data from a SaaS API into a local DuckDB file
2. **Model** — Introspect the DuckDB schema and auto-generate a Wren semantic project (YAML models, relationships, profile)
3. **Build & Verify** — Build the project and run actual SQL queries to confirm everything works end-to-end
4. **Handoff** — Show the user their data and next steps
The user might enter at any phase. Ask which phase they're starting from — they may already have a `.duckdb` file and just need phases 24.
**The goal is a project that actually queries successfully, not just files that look correct.** Always run the verification step before declaring success.
## Critical: DuckDB catalog naming
When wren engine connects to a DuckDB file, it ATTACHes it using the filename (without `.duckdb` extension) as the catalog alias:
```
ATTACH DATABASE 'stripe_data.duckdb' AS "stripe_data" (READ_ONLY)
```
This means **every model's `table_reference.catalog` must equal the DuckDB filename stem**. If the file is `hubspot.duckdb`, the catalog is `hubspot`. If it's `my_pipeline.duckdb`, the catalog is `my_pipeline`.
Getting this wrong causes "table not found" errors at query time. The `introspect_dlt.py` script handles this automatically.
## Critical: Type normalization
Column types must be normalized using wren SDK's `type_mapping.parse_type()` function, which uses sqlglot to convert database-specific types (like DuckDB's `HUGEINT`, `TIMESTAMP WITH TIME ZONE`) into canonical SQL types that wren-core understands. Do not hardcode type mappings — always delegate to `parse_type(raw_type, "duckdb")`.
The `introspect_dlt.py` script does this automatically when wren SDK is installed.
## Phase 1: Extract — dlt Pipeline Setup
### Step 1: Pick the SaaS source
Ask the user which SaaS service they want to connect. Read `references/dlt_sources.md` for a list of popular verified sources and their auth requirements. If the source isn't listed, check whether dlt has a verified source for it by searching `dlthub.com/docs/dlt-ecosystem/verified-sources`.
### Step 2: Install dlt
```bash
pip install "dlt[duckdb]" --break-system-packages
```
### Step 3: Write the pipeline script
Create a Python script that:
1. Imports the dlt source function for the chosen SaaS
2. Configures the pipeline with `destination='duckdb'` and a local file path
3. Runs the pipeline with `pipeline.run(source)`
Here's the general pattern — adapt it per source (check `references/dlt_sources.md` for source-specific templates):
```python
import dlt
pipeline = dlt.pipeline(
pipeline_name="<source>_pipeline",
destination="duckdb",
dataset_name="<source>_data",
)
# Source-specific: check references/dlt_sources.md for auth patterns
source = <source_function>(api_key=dlt.secrets.value)
info = pipeline.run(source)
print(info)
```
### Step 4: Set up credentials
dlt reads credentials from environment variables or `.dlt/secrets.toml`. The simplest approach for a one-time run:
```bash
# Set the credential as an environment variable
# The exact variable name depends on the source — check references/dlt_sources.md
export SOURCES__<SOURCE>__API_KEY="the-actual-key"
```
Ask the user for their API key or token. Remind them:
- Never commit credentials to git
- Environment variables are the simplest way for a one-time run
- For repeated use, they can create `.dlt/secrets.toml`
### Step 5: Run the pipeline
```bash
python <pipeline_script>.py
```
After the run, confirm:
1. The pipeline completed without errors
2. A `.duckdb` file was created (usually at `<pipeline_name>.duckdb`)
3. Print discovered tables and their column counts
```python
import duckdb
con = duckdb.connect("<pipeline_name>.duckdb", read_only=True)
for row in con.execute("""
SELECT table_schema, table_name,
(SELECT COUNT(*) FROM information_schema.columns c
WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name) as col_count
FROM information_schema.tables t
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
AND table_name NOT LIKE '_dlt_%'
ORDER BY table_schema, table_name
""").fetchall():
print(f" {row[0]}.{row[1]} ({row[2]} columns)")
con.close()
```
## Phase 2: Model — Generate Wren Project
Run the introspection script to auto-generate a complete Wren project from the DuckDB file:
```bash
python <path-to-this-skill>/scripts/introspect_dlt.py \
--duckdb-path <path-to-duckdb-file> \
--output-dir <project-directory> \
--project-name <name>
```
This script:
- Connects to the DuckDB file (read-only)
- **Sets `table_reference.catalog` to the DuckDB filename stem** (matching wren engine's ATTACH behavior)
- Discovers all tables and columns via `information_schema`
- Filters out dlt internal tables (`_dlt_loads`, `_dlt_pipeline_state`, etc.)
- Filters out dlt metadata columns (`_dlt_id`, `_dlt_load_id`, `_dlt_list_idx`) from model definitions
- Detects parent-child relationships from `_dlt_parent_id` columns and table naming conventions
- **Normalizes column types using `wren.type_mapping.parse_type()`** (sqlglot-based)
- Generates a complete v2 YAML project (wren_project.yml, models/, relationships.yml, instructions.md)
After running, show the user what was generated:
```bash
# Show project summary
cat <project-directory>/wren_project.yml
echo "---"
ls <project-directory>/models/
echo "---"
cat <project-directory>/relationships.yml
```
### Verify model correctness
Spot-check one generated model to confirm:
1. `table_reference.catalog` matches the DuckDB filename (e.g., `stripe_data` for `stripe_data.duckdb`)
2. `table_reference.schema` matches the DuckDB schema (usually `main`)
3. No `_dlt_*` columns appear in the columns list
4. Column types look reasonable (VARCHAR, BIGINT, BOOLEAN, TIMESTAMP, etc.)
### Set up the connection profile
Create a Wren profile so the user can query without specifying connection details every time. The `url` must point to the **directory containing** the `.duckdb` file (not the file itself):
```python
import yaml
from pathlib import Path
wren_home = Path.home() / ".wren"
wren_home.mkdir(exist_ok=True)
profiles_file = wren_home / "profiles.yml"
existing = (yaml.safe_load(profiles_file.read_text()) or {}) if profiles_file.exists() else {}
existing.setdefault("profiles", {})
profile_name = "<source>_dlt"
existing["profiles"][profile_name] = {
"datasource": "duckdb",
"url": str(Path("<duckdb-path>").resolve().parent),
"format": "duckdb",
}
existing["active"] = profile_name
profiles_file.write_text(yaml.dump(existing, default_flow_style=False, sort_keys=False))
```
## Phase 3: Build & Verify — The Project Must Actually Work
This phase is not optional. A project that generates YAML but fails at query time is not a success.
### Step 1: Build the MDL
```bash
cd <project-directory>
wren context build
```
This compiles the YAML models into `target/mdl.json`. If this fails, fix the issues before proceeding (see Troubleshooting below).
### Step 2: Validate with a real query
Run at least one query per generated model to confirm the project is functional:
```bash
# For each model, verify it resolves correctly
wren --sql 'SELECT COUNT(*) as total FROM "<table_name>"'
```
If any query fails, debug and fix the model before moving on. Common issues:
- Wrong catalog in table_reference → "table not found"
- Type mismatch → fix the column type in metadata.yml
- Missing profile → check `wren profile list`
### Step 3: Run interesting queries
Once basic queries pass, run 23 more interesting queries to show the user what their data looks like:
```bash
# Preview data
wren --sql 'SELECT * FROM "<table_name>" LIMIT 5'
# If there's a relationship, verify both models are queryable
wren --sql 'SELECT * FROM "<parent>" LIMIT 5'
wren --sql 'SELECT * FROM "<child>" LIMIT 5'
```
Show the results to the user and explain what they're seeing. This is their first look at the data through Wren — make it count.
### Step 4: Confirm success
Only after queries return real data, tell the user the setup is complete. Summarize:
- How many models were created
- What relationships were detected
- Which profile is active
- Example queries they can try next
## Troubleshooting
If `wren context build` fails:
- Check that `data_source: duckdb` is set in `wren_project.yml`
- Verify the DuckDB file path in the profile is correct
- Run `wren context validate` for detailed error messages
If queries fail with "table not found":
- **Most likely cause:** `table_reference.catalog` doesn't match the DuckDB filename. If the file is `pipeline.duckdb`, the catalog must be `pipeline`, not empty string.
- Check the profile's `url` points to the directory containing the `.duckdb` file
- Table names with double underscores need quoting: `"hubspot__contacts"`
If queries fail with type errors:
- Check column types in the model YAML — they should be canonical SQL types (VARCHAR, BIGINT, etc.)
- Re-run `introspect_dlt.py` with wren SDK installed to get proper type normalization
General:
- Check that the profile is active: `wren profile list`
- The DuckDB file might be locked if a dlt pipeline is running — wait for it to finish
## Important notes
- dlt's `_dlt_parent_id` / `_dlt_id` columns are kept in the actual DuckDB tables but hidden from Wren model definitions. They're only used in relationship conditions.
- DuckDB has a single-writer limitation. Don't run a dlt sync while querying. For concurrent access, dlt should write to a separate file and swap atomically.
- The generated models use `table_reference` (not `ref_sql`) since they map directly to DuckDB tables created by dlt.
- Column types are normalized using wren SDK's `parse_type()` with sqlglot's DuckDB dialect. If a type looks wrong, the user can edit the model's `metadata.yml` directly.
@@ -0,0 +1,23 @@
{
"skill_name": "wren-dlt-connector",
"evals": [
{
"id": 1,
"prompt": "我想把公司 HubSpot CRM 的資料拉進來用 SQL 分析,可以幫我設定嗎?我有 HubSpot 的 private app token。",
"expected_output": "Should guide through: install dlt, write a HubSpot pipeline script, set credentials, run pipeline, introspect DuckDB, generate wren project, build, and run sample queries on contacts/deals tables.",
"files": []
},
{
"id": 2,
"prompt": "I already have a DuckDB file from a dlt pipeline at ./stripe_data.duckdb. I want to create a wren project so I can query the Stripe data with SQL. Can you set that up?",
"expected_output": "Should skip Phase 1 (dlt setup), go directly to introspecting the DuckDB file, generate wren project YAML, set up profile, build, and run sample queries.",
"files": []
},
{
"id": 3,
"prompt": "我們團隊用 GitHub 管理 open source project,我想定期把 issues 和 PR 資料抓下來做分析。怎麼開始?",
"expected_output": "Should guide through: install dlt, configure GitHub access token, write pipeline for github source (issues + PRs), run into DuckDB, generate wren project with models for issues/pull_requests/comments, detect relationships, build, run sample queries.",
"files": []
}
]
}
@@ -0,0 +1,159 @@
# Popular dlt Verified Sources — Quick Reference
## Source Auth Patterns
| Source | Auth Method | Credential | Env Variable |
|--------|-----------|------------|-------------|
| HubSpot | Private App Token | PAT string | `SOURCES__HUBSPOT__API_KEY` |
| Stripe | Secret Key | `sk_live_...` or `sk_test_...` | `SOURCES__STRIPE_ANALYTICS__STRIPE_SECRET_KEY` |
| Salesforce | Username + Password + Security Token | 3 fields | `SOURCES__SALESFORCE__USERNAME`, `__PASSWORD`, `__SECURITY_TOKEN` |
| GitHub | Personal Access Token | `ghp_...` | `SOURCES__GITHUB__ACCESS_TOKEN` |
| Slack | Bot Token | `xoxb-...` | `SOURCES__SLACK__ACCESS_TOKEN` |
| Google Analytics | Service Account JSON | JSON key file | `SOURCES__GOOGLE_ANALYTICS__CREDENTIALS` (JSON string or file path) |
| Google Sheets | Service Account JSON | JSON key file | `SOURCES__GOOGLE_SHEETS__CREDENTIALS` |
| Notion | Integration Token | `secret_...` | `SOURCES__NOTION__API_KEY` |
| Jira | Email + API Token | 2 fields | `SOURCES__JIRA__SUBDOMAIN`, `__EMAIL`, `__API_TOKEN` |
| Zendesk | Email + API Token | 2 fields | `SOURCES__ZENDESK__SUBDOMAIN`, `__EMAIL`, `__PASSWORD` |
| Shopify | Admin API Access Token | token string | `SOURCES__SHOPIFY__PRIVATE_APP_PASSWORD` |
| Airtable | Personal Access Token | `pat...` | `SOURCES__AIRTABLE__ACCESS_TOKEN` |
## Pipeline Script Templates
### HubSpot
```python
import dlt
from dlt.sources.rest_api import rest_api_source
# Or use the dedicated hubspot source if available:
# dlt init hubspot duckdb
# Then: from hubspot import hubspot
pipeline = dlt.pipeline(
pipeline_name="hubspot",
destination="duckdb",
dataset_name="hubspot_data",
)
# With the verified source:
from hubspot import hubspot_source
source = hubspot_source(api_key=dlt.secrets.value)
info = pipeline.run(source)
print(info)
```
**Typical tables produced:** contacts, companies, deals, tickets, owners, pipelines, stages, emails, calls, meetings, notes, tasks, products, line_items, quotes
### Stripe
```python
import dlt
from stripe_analytics import stripe_source
pipeline = dlt.pipeline(
pipeline_name="stripe",
destination="duckdb",
dataset_name="stripe_data",
)
source = stripe_source()
info = pipeline.run(source)
print(info)
```
**Typical tables produced:** customers, charges, invoices, subscriptions, products, prices, payment_intents, refunds, balance_transactions, events
### GitHub
```python
import dlt
from github import github_reactions, github_repo_events
pipeline = dlt.pipeline(
pipeline_name="github",
destination="duckdb",
dataset_name="github_data",
)
source = github_reactions("owner/repo", access_token=dlt.secrets.value)
info = pipeline.run(source)
print(info)
```
**Typical tables produced:** issues, pull_requests, comments, reactions, stargazers, commits, events
### Slack
```python
import dlt
from datetime import datetime
from slack import slack_source
pipeline = dlt.pipeline(
pipeline_name="slack",
destination="duckdb",
dataset_name="slack_data",
)
source = slack_source(
selected_channels=["general", "engineering"],
start_date=datetime(2024, 1, 1),
)
info = pipeline.run(source)
print(info)
```
**Typical tables produced:** channels, messages, users, threads, files, reactions
### Salesforce
```python
import dlt
from salesforce import salesforce_source
pipeline = dlt.pipeline(
pipeline_name="salesforce",
destination="duckdb",
dataset_name="salesforce_data",
)
source = salesforce_source()
info = pipeline.run(source)
print(info)
```
**Typical tables produced:** accounts, contacts, leads, opportunities, cases, tasks, events, campaigns, users
## How to Get Credentials
### HubSpot
1. Go to HubSpot → Settings → Integrations → Private Apps
2. Create a private app with the scopes you need (contacts, companies, deals, etc.)
3. Copy the access token
### Stripe
1. Go to Stripe Dashboard → Developers → API keys
2. Copy the Secret key (use test mode key for testing)
### GitHub
1. Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
2. Create a token with repo read access
### Slack
1. Go to api.slack.com → Your Apps → Create New App
2. Add Bot Token Scopes: channels:history, channels:read, users:read
3. Install to workspace, copy Bot User OAuth Token
### Salesforce
1. Your Salesforce username (email)
2. Your Salesforce password
3. Security token: Salesforce → Settings → Reset My Security Token (sent via email)
## dlt Init Shortcut
For verified sources, dlt provides a scaffolding command:
```bash
dlt init <source_name> duckdb
```
This creates a pipeline script and secrets template. Supported source names:
hubspot, stripe_analytics, salesforce, github, slack, google_analytics, google_sheets, notion, jira, zendesk, shopify, airtable, asana, chess, pokemon, pipedrive, freshdesk, matomo, mongodb, sql_database, rest_api
@@ -0,0 +1,448 @@
#!/usr/bin/env python3
"""Introspect a dlt-produced DuckDB file and generate a Wren v2 YAML project.
Usage:
python introspect_dlt.py --duckdb-path ./pipeline.duckdb --output-dir ./my_project
This script:
1. Connects to a DuckDB file (read-only)
2. Discovers tables and columns via information_schema
3. Filters out dlt internal tables and metadata columns
4. Detects parent-child relationships from _dlt_parent_id
5. Normalizes column types using wren's type_mapping.parse_type (sqlglot)
6. Generates a complete Wren v2 YAML project
7. Optionally verifies the project builds and queries succeed
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import duckdb
import yaml
# ---------------------------------------------------------------------------
# dlt internal tables and columns
# ---------------------------------------------------------------------------
_DLT_METADATA_COLUMNS = frozenset(
{
"_dlt_id",
"_dlt_parent_id",
"_dlt_load_id",
"_dlt_list_idx",
}
)
_EXCLUDED_SCHEMAS = frozenset({"information_schema", "pg_catalog"})
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class Column:
name: str
raw_type: str # original DuckDB type
wren_type: str # normalized via parse_type
is_nullable: bool
@dataclass
class Table:
catalog: str
schema: str
name: str
columns: list[Column] = field(default_factory=list)
has_dlt_parent_id: bool = False
@dataclass
class Relationship:
name: str
parent_model: str
child_model: str
condition: str
join_type: str = "ONE_TO_MANY"
schema: str = ""
# ---------------------------------------------------------------------------
# Type normalization — delegates to wren SDK's parse_type when available
# ---------------------------------------------------------------------------
def _normalize_type(raw_type: str) -> str:
"""Normalize a DuckDB column type using wren's type_mapping.parse_type.
Wren SDK uses sqlglot to parse database-specific types into canonical
SQL forms (e.g. "character varying""VARCHAR", "INT8""BIGINT").
Falls back to uppercase raw type if wren SDK is not importable.
"""
try:
from wren.type_mapping import parse_type # noqa: PLC0415
return parse_type(raw_type, "duckdb")
except ImportError:
# Fallback if wren SDK is not installed in the environment
return raw_type.upper().strip()
# ---------------------------------------------------------------------------
# Catalog resolution — critical for DuckDB ATTACH behavior
# ---------------------------------------------------------------------------
def _resolve_catalog(duckdb_path: Path) -> str:
"""Derive the DuckDB catalog name from the file path.
When wren engine connects to a DuckDB file, it runs:
ATTACH DATABASE 'path/to/file.duckdb' AS "<stem>" (READ_ONLY)
where <stem> is the filename without extension. This means the catalog
in table_reference MUST match the filename stem, otherwise wren engine
cannot resolve the table.
Example: stripe_data.duckdb → catalog = "stripe_data"
"""
return duckdb_path.stem
# ---------------------------------------------------------------------------
# Introspection
# ---------------------------------------------------------------------------
def discover_tables(
con: duckdb.DuckDBPyConnection,
*,
catalog_name: str,
) -> list[Table]:
"""Query information_schema to find all user tables with their columns.
Args:
con: DuckDB connection (read-only).
catalog_name: The catalog name that wren engine will use when
ATTACHing this DuckDB file (= filename stem).
"""
rows = con.execute(
"""
SELECT
t.table_schema,
t.table_name,
c.column_name,
c.data_type,
c.is_nullable,
c.ordinal_position
FROM information_schema.tables t
JOIN information_schema.columns c
ON t.table_schema = c.table_schema
AND t.table_name = c.table_name
WHERE t.table_type IN ('BASE TABLE', 'VIEW')
AND t.table_schema NOT IN ('information_schema', 'pg_catalog')
AND t.table_name NOT LIKE '_dlt_%'
ORDER BY t.table_schema, t.table_name, c.ordinal_position
"""
).fetchall()
tables: dict[str, Table] = {}
for schema, table_name, col_name, col_type, nullable, _pos in rows:
key = f"{schema}.{table_name}"
if key not in tables:
tables[key] = Table(
catalog=catalog_name,
schema=schema,
name=table_name,
)
t = tables[key]
# Track _dlt_parent_id presence for relationship detection
if col_name == "_dlt_parent_id":
t.has_dlt_parent_id = True
# Skip dlt metadata columns from model columns
if col_name in _DLT_METADATA_COLUMNS:
continue
normalized = _normalize_type(col_type)
t.columns.append(
Column(
name=col_name,
raw_type=col_type,
wren_type=normalized,
is_nullable=nullable == "YES",
)
)
return list(tables.values())
def detect_relationships(tables: list[Table]) -> list[Relationship]:
"""Detect parent-child relationships from _dlt_parent_id and table naming.
dlt convention: child table name = parent_name__child_suffix
e.g. hubspot__contacts__emails is a child of hubspot__contacts
"""
tables_by_schema: dict[str, set[str]] = {}
for t in tables:
tables_by_schema.setdefault(t.schema, set()).add(t.name)
relationships: list[Relationship] = []
for t in tables:
if not t.has_dlt_parent_id:
continue
# Find parent: try progressively shorter prefixes split on "__"
parts = t.name.split("__")
parent_name = None
child_suffix = None
for i in range(len(parts) - 1, 0, -1):
candidate = "__".join(parts[:i])
if candidate in tables_by_schema.get(t.schema, set()) and candidate != t.name:
parent_name = candidate
child_suffix = "__".join(parts[i:])
break
if parent_name is None:
print(
f" Warning: {t.name} has _dlt_parent_id but no matching parent table found",
file=sys.stderr,
)
continue
rel_name = f"{parent_name}__{child_suffix}"
relationships.append(
Relationship(
name=rel_name,
parent_model=parent_name,
child_model=t.name,
condition=f'"{t.name}"._dlt_parent_id = "{parent_name}"._dlt_id',
schema=t.schema,
)
)
return relationships
# ---------------------------------------------------------------------------
# Project generation
# ---------------------------------------------------------------------------
def _safe_path_segment(value: str) -> str:
"""Sanitize a string for use as a filesystem directory name."""
cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", value).strip("._")
if not cleaned:
raise ValueError(f"Invalid path segment from identifier: {value!r}")
return cleaned
def generate_project_files(
tables: list[Table],
relationships: list[Relationship],
*,
project_name: str,
duckdb_path: str,
) -> dict[str, str]:
"""Generate all project file contents as {relative_path: content} dict."""
files: dict[str, str] = {}
# Detect cross-schema name collisions; qualify model names when needed
name_counts: dict[str, int] = {}
for t in tables:
name_counts[t.name] = name_counts.get(t.name, 0) + 1
def resolve_name(schema: str, table_name: str) -> str:
return f"{schema}__{table_name}" if name_counts.get(table_name, 0) > 1 else table_name
# -- wren_project.yml --
project_config = {
"schema_version": 2,
"name": project_name,
"version": "1.0",
"catalog": "",
"schema": "public",
"data_source": "duckdb",
}
files["wren_project.yml"] = yaml.dump(
project_config, default_flow_style=False, sort_keys=False
)
# -- models/<table_name>/metadata.yml --
for t in tables:
model_name = resolve_name(t.schema, t.name)
columns_yaml = []
for c in t.columns:
col_entry: dict = {
"name": c.name,
"type": c.wren_type,
"is_calculated": False,
"not_null": not c.is_nullable,
"properties": {},
}
columns_yaml.append(col_entry)
model: dict = {
"name": model_name,
"table_reference": {
"catalog": t.catalog,
"schema": t.schema,
"table": t.name,
},
"columns": columns_yaml,
"cached": False,
"properties": {
"description": f"Table from dlt pipeline ({t.schema}.{t.name})",
},
}
dir_name = _safe_path_segment(model_name)
files[f"models/{dir_name}/metadata.yml"] = yaml.dump(
model, default_flow_style=False, sort_keys=False
)
# -- relationships.yml --
if relationships:
rels_yaml = []
for r in relationships:
parent = resolve_name(r.schema, r.parent_model)
child = resolve_name(r.schema, r.child_model)
rels_yaml.append(
{
"name": r.name,
"models": [parent, child],
"join_type": r.join_type,
"condition": f'"{child}"._dlt_parent_id = "{parent}"._dlt_id',
}
)
files["relationships.yml"] = yaml.dump(
{"relationships": rels_yaml},
default_flow_style=False,
sort_keys=False,
)
else:
files["relationships.yml"] = "relationships: []\n"
# -- instructions.md --
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
instructions = (
"# Instructions\n\n"
f"This Wren project was auto-generated from a dlt DuckDB pipeline.\n\n"
f"- **Source DuckDB:** `{duckdb_path}`\n"
f"- **Generated:** {timestamp}\n"
f"- **Tables:** {len(tables)}\n"
f"- **Relationships:** {len(relationships)}\n\n"
"dlt metadata columns (`_dlt_id`, `_dlt_parent_id`, etc.) are hidden from models\n"
"but still present in the underlying DuckDB tables.\n"
)
files["instructions.md"] = instructions
return files
def write_project(files: dict[str, str], output_dir: Path, *, force: bool = False):
"""Write generated files to disk."""
project_file = output_dir / "wren_project.yml"
if project_file.exists() and not force:
print(
f"Error: {project_file} already exists. Use --force to overwrite.",
file=sys.stderr,
)
sys.exit(1)
for rel_path, content in files.items():
path = output_dir / rel_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Generate a Wren project from a dlt DuckDB file."
)
parser.add_argument(
"--duckdb-path", required=True, help="Path to the .duckdb file"
)
parser.add_argument(
"--output-dir",
default=".",
help="Directory to write the Wren project (default: current dir)",
)
parser.add_argument(
"--project-name",
default=None,
help="Project name (default: derived from DuckDB filename)",
)
parser.add_argument(
"--force", action="store_true", help="Overwrite existing project files"
)
args = parser.parse_args()
duckdb_path = Path(args.duckdb_path).resolve()
if not duckdb_path.exists():
print(f"Error: {duckdb_path} not found.", file=sys.stderr)
sys.exit(1)
project_name = args.project_name or duckdb_path.stem.replace("-", "_")
output_dir = Path(args.output_dir).resolve()
# The catalog must match the filename stem — this is how wren engine's
# DuckDB connector ATTACHes the file.
catalog_name = _resolve_catalog(duckdb_path)
# Connect read-only
con = duckdb.connect(str(duckdb_path), read_only=True)
try:
print(f"Introspecting {duckdb_path}...")
print(f" Catalog (from filename): {catalog_name}")
tables = discover_tables(con, catalog_name=catalog_name)
print(f" Found {len(tables)} tables")
if not tables:
print(
" Warning: no user tables found. The DuckDB file may be empty.",
file=sys.stderr,
)
relationships = detect_relationships(tables)
print(f" Detected {len(relationships)} parent-child relationships")
files = generate_project_files(
tables,
relationships,
project_name=project_name,
duckdb_path=str(duckdb_path),
)
write_project(files, output_dir, force=args.force)
print(f"\nWren project written to {output_dir}/")
print(f" {len(tables)} models, {len(relationships)} relationships")
print(f"\nNext steps:")
print(f" cd {output_dir}")
print(f" wren context validate")
print(f" wren context build")
finally:
con.close()
if __name__ == "__main__":
main()