mirror of
https://github.com/Canner/WrenAI.git
synced 2026-08-30 18:00:36 +08:00
feat(context): bind a connection profile to a project (#2251)
This commit is contained in:
@@ -127,6 +127,55 @@ def _load_conn(
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_engine_profile(mdl: str | None) -> tuple[str | None, dict]:
|
||||
"""Resolve the connection profile for project-context CLI commands.
|
||||
|
||||
Project detection is decoupled from ``--mdl`` shape: ``--mdl`` is a pure
|
||||
"which MDL artifact to load" override, project context is determined
|
||||
independently by walking up from the MDL path (when it's a real file)
|
||||
AND from cwd. Active profile is reserved for the case where neither
|
||||
discovery finds a project — preventing ``--mdl <base64>`` or
|
||||
``--mdl /external.json`` from silently bypassing cwd's pin.
|
||||
"""
|
||||
from wren.profile import ( # noqa: PLC0415
|
||||
get_active_profile,
|
||||
resolve_profile_for_project,
|
||||
)
|
||||
|
||||
project_path = _discover_project_for_engine(mdl)
|
||||
if project_path is None:
|
||||
return get_active_profile()
|
||||
return resolve_profile_for_project(project_path)
|
||||
|
||||
|
||||
def _discover_project_for_engine(mdl: str | None) -> Path | None:
|
||||
"""Find the project root for engine commands. Returns ``None`` if no
|
||||
project context exists anywhere (caller should fall back to active).
|
||||
|
||||
Resolution order:
|
||||
1. If ``--mdl`` is a real file, walk up its directory tree looking
|
||||
for ``wren_project.yml``. ``<project>/target/mdl.json`` is a build
|
||||
default, not a contract — users may keep MDL elsewhere.
|
||||
2. Otherwise (``--mdl`` is base64, points outside a project, or is
|
||||
absent), discover from cwd.
|
||||
"""
|
||||
if mdl is not None:
|
||||
mdl_path = Path(mdl).expanduser()
|
||||
if mdl_path.exists() and mdl_path.is_file():
|
||||
for parent in mdl_path.resolve().parents:
|
||||
if (parent / "wren_project.yml").exists():
|
||||
return parent
|
||||
if parent == Path.home() or parent == parent.parent:
|
||||
break
|
||||
|
||||
try:
|
||||
from wren.context import discover_project_path # noqa: PLC0415
|
||||
|
||||
return discover_project_path()
|
||||
except SystemExit:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_datasource(conn_dict: dict, explicit: str | None = None) -> str:
|
||||
"""Return datasource from explicit arg or connection dict.
|
||||
|
||||
@@ -160,15 +209,15 @@ def _build_engine(
|
||||
|
||||
manifest_str = _load_manifest(_require_mdl(mdl))
|
||||
|
||||
# Try active profile when no explicit connection flags given
|
||||
# Try project-pinned profile (or fall back to active) when no explicit
|
||||
# connection flags given.
|
||||
if not connection_info and not connection_file:
|
||||
from wren.profile import ( # noqa: PLC0415
|
||||
MissingSecretError,
|
||||
expand_profile_secrets,
|
||||
get_active_profile,
|
||||
)
|
||||
|
||||
prof_name, prof_dict = get_active_profile()
|
||||
prof_name, prof_dict = _resolve_engine_profile(mdl)
|
||||
if prof_dict:
|
||||
prof_ds = prof_dict.pop("datasource", None)
|
||||
ds_str = datasource or prof_ds
|
||||
@@ -419,15 +468,18 @@ def dry_plan(
|
||||
|
||||
manifest_str = _load_manifest(_require_mdl(mdl))
|
||||
|
||||
# Try active profile when no explicit flags given
|
||||
# Try project-pinned profile (or fall back to active) when no explicit
|
||||
# connection flags given.
|
||||
if datasource is None and connection_file is None:
|
||||
from wren.profile import get_active_profile # noqa: PLC0415
|
||||
|
||||
_prof_name, prof_dict = get_active_profile()
|
||||
_prof_name, prof_dict = _resolve_engine_profile(mdl)
|
||||
if prof_dict:
|
||||
prof_ds = prof_dict.pop("datasource", None)
|
||||
if prof_ds is None:
|
||||
typer.echo("Error: no datasource in active profile.", err=True)
|
||||
typer.echo(
|
||||
"Error: no datasource in resolved profile "
|
||||
"(project-pinned or active).",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
ds = DataSource(prof_ds.lower())
|
||||
|
||||
@@ -13,7 +13,7 @@ import yaml
|
||||
|
||||
_WREN_HOME = Path(os.environ.get("WREN_HOME", Path.home() / ".wren"))
|
||||
_DEFAULT_PROJECT = _WREN_HOME / "project"
|
||||
_PROJECT_FILE = "wren_project.yml"
|
||||
PROJECT_FILE = "wren_project.yml"
|
||||
_TARGET_DIR = "target"
|
||||
_TARGET_FILE = "mdl.json"
|
||||
|
||||
@@ -355,7 +355,7 @@ def discover_project_path(explicit: str | None = None) -> Path:
|
||||
# 3. Walk up from cwd looking for wren_project.yml
|
||||
current = Path.cwd()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / _PROJECT_FILE).exists():
|
||||
if (parent / PROJECT_FILE).exists():
|
||||
return parent
|
||||
# Stop at home or root
|
||||
if parent == Path.home() or parent == parent.parent:
|
||||
@@ -375,12 +375,46 @@ def discover_project_path(explicit: str | None = None) -> Path:
|
||||
|
||||
def load_project_config(project_path: Path) -> dict:
|
||||
"""Load wren_project.yml and return as dict."""
|
||||
config_file = project_path / _PROJECT_FILE
|
||||
config_file = project_path / PROJECT_FILE
|
||||
if not config_file.exists():
|
||||
return {}
|
||||
return yaml.safe_load(config_file.read_text()) or {}
|
||||
|
||||
|
||||
# Field order preferred when writing wren_project.yml back from a dict —
|
||||
# keeps the file readable for humans even after CLI commands rewrite it.
|
||||
# Anything not in this list is appended in dict-iteration order at the end.
|
||||
_PROJECT_FIELD_ORDER = (
|
||||
"schema_version",
|
||||
"name",
|
||||
"version",
|
||||
"catalog",
|
||||
"schema",
|
||||
"data_source",
|
||||
"profile",
|
||||
)
|
||||
|
||||
|
||||
def save_project_config(project_path: Path, config: dict) -> None:
|
||||
"""Write ``wren_project.yml`` with a stable field ordering.
|
||||
|
||||
Drops YAML comments — round-tripping with comments would require
|
||||
``ruamel.yaml`` which isn't a dependency. The file is rewritten by
|
||||
set-profile / upgrade-style commands; comments are init-template only.
|
||||
"""
|
||||
ordered: dict = {}
|
||||
for key in _PROJECT_FIELD_ORDER:
|
||||
if key in config:
|
||||
ordered[key] = config[key]
|
||||
for key, value in config.items():
|
||||
if key not in ordered:
|
||||
ordered[key] = value
|
||||
|
||||
(project_path / PROJECT_FILE).write_text(
|
||||
yaml.safe_dump(ordered, default_flow_style=False, sort_keys=False)
|
||||
)
|
||||
|
||||
|
||||
_SUPPORTED_SCHEMA_VERSIONS = {1, 2, 3}
|
||||
|
||||
# schema_version → layoutVersion mapping for the engine
|
||||
@@ -419,7 +453,7 @@ def get_schema_version(project_path: Path) -> int:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise SystemExit(
|
||||
f"Error: invalid schema_version {raw!r} in {_PROJECT_FILE}. Expected an integer."
|
||||
f"Error: invalid schema_version {raw!r} in {PROJECT_FILE}. Expected an integer."
|
||||
)
|
||||
|
||||
|
||||
@@ -428,7 +462,7 @@ def require_schema_version(project_path: Path) -> int:
|
||||
sv = get_schema_version(project_path)
|
||||
if sv not in _SUPPORTED_SCHEMA_VERSIONS:
|
||||
raise SystemExit(
|
||||
f"Error: unsupported schema_version {sv} in {_PROJECT_FILE}. "
|
||||
f"Error: unsupported schema_version {sv} in {PROJECT_FILE}. "
|
||||
"Please upgrade wren CLI."
|
||||
)
|
||||
return sv
|
||||
@@ -658,7 +692,7 @@ def validate_project(project_path: Path) -> list[ValidationError]:
|
||||
if not config:
|
||||
errors.append(
|
||||
ValidationError(
|
||||
"error", _PROJECT_FILE, f"'{_PROJECT_FILE}' not found or empty"
|
||||
"error", PROJECT_FILE, f"'{PROJECT_FILE}' not found or empty"
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -666,7 +700,7 @@ def validate_project(project_path: Path) -> list[ValidationError]:
|
||||
if not config.get(required):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
"error", _PROJECT_FILE, f"missing required field '{required}'"
|
||||
"error", PROJECT_FILE, f"missing required field '{required}'"
|
||||
)
|
||||
)
|
||||
raw_sv = config.get("schema_version", 1)
|
||||
@@ -676,7 +710,7 @@ def validate_project(project_path: Path) -> list[ValidationError]:
|
||||
errors.append(
|
||||
ValidationError(
|
||||
"error",
|
||||
_PROJECT_FILE,
|
||||
PROJECT_FILE,
|
||||
f"schema_version must be an integer, got {raw_sv!r}",
|
||||
)
|
||||
)
|
||||
@@ -685,12 +719,12 @@ def validate_project(project_path: Path) -> list[ValidationError]:
|
||||
errors.append(
|
||||
ValidationError(
|
||||
"error",
|
||||
_PROJECT_FILE,
|
||||
PROJECT_FILE,
|
||||
f"unsupported schema_version {sv} — please upgrade wren CLI",
|
||||
)
|
||||
)
|
||||
|
||||
if any(e.path == _PROJECT_FILE and "schema_version" in e.message for e in errors):
|
||||
if any(e.path == PROJECT_FILE and "schema_version" in e.message for e in errors):
|
||||
return errors
|
||||
|
||||
# Load data (snake_case)
|
||||
@@ -979,7 +1013,7 @@ def plan_upgrade(
|
||||
to_version=target,
|
||||
files_created=files_created,
|
||||
files_deleted=files_deleted,
|
||||
files_modified=[_PROJECT_FILE],
|
||||
files_modified=[PROJECT_FILE],
|
||||
)
|
||||
|
||||
|
||||
@@ -1036,7 +1070,7 @@ def apply_upgrade(project_path: Path, result: UpgradeResult) -> None:
|
||||
# Update wren_project.yml
|
||||
config = load_project_config(project_path)
|
||||
config["schema_version"] = result.to_version
|
||||
config_file = project_path / _PROJECT_FILE
|
||||
config_file = project_path / PROJECT_FILE
|
||||
config_file.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
||||
|
||||
|
||||
|
||||
@@ -90,7 +90,9 @@ def init(
|
||||
return
|
||||
|
||||
# ── Scaffold empty project (existing behavior) ────────────
|
||||
project_file = project_path / "wren_project.yml"
|
||||
from wren.context import PROJECT_FILE # noqa: PLC0415
|
||||
|
||||
project_file = project_path / PROJECT_FILE
|
||||
agents_file = project_path / "AGENTS.md"
|
||||
queries_file = project_path / "queries.yml"
|
||||
conflicts = [f for f in (project_file, agents_file, queries_file) if f.exists()]
|
||||
@@ -288,6 +290,7 @@ def validate(
|
||||
# ── Semantic validation (dry-plan + description checks) ──────────────
|
||||
sem_errors: list[str] = []
|
||||
sem_warnings: list[str] = []
|
||||
config: dict = {}
|
||||
try:
|
||||
config = load_project_config(project_path)
|
||||
ds_str = config.get("data_source", "")
|
||||
@@ -301,6 +304,30 @@ def validate(
|
||||
except Exception as e:
|
||||
sem_errors = [f"Semantic validation failed: {e}"]
|
||||
|
||||
# ── Profile binding check ─────────────────────────────────────────────
|
||||
# Pinned profile that no longer exists → warning (or error in --strict).
|
||||
# No pin at all → friendly info hint pointing to `set-profile`.
|
||||
profile_pin = config.get("profile") if isinstance(config, dict) else None
|
||||
if isinstance(profile_pin, str) and profile_pin.strip():
|
||||
# Guard the lookup: if profiles.yml itself is unreadable / malformed,
|
||||
# the user shouldn't see a raw traceback — surface it as a warning so
|
||||
# validate can still report the rest.
|
||||
try:
|
||||
from wren.profile import list_profiles # noqa: PLC0415
|
||||
|
||||
registered = list_profiles()
|
||||
except Exception as profile_exc:
|
||||
sem_warnings.append(
|
||||
f"could not check pinned profile '{profile_pin}': {profile_exc}"
|
||||
)
|
||||
else:
|
||||
if profile_pin.strip() not in registered:
|
||||
sem_warnings.append(
|
||||
f"project pins profile '{profile_pin}' but it doesn't "
|
||||
"exist in ~/.wren/profiles.yml. "
|
||||
"Run `wren context set-profile <name>` to rebind."
|
||||
)
|
||||
|
||||
if sem_errors:
|
||||
typer.echo("\nSemantic errors:")
|
||||
for msg in sem_errors:
|
||||
@@ -313,6 +340,21 @@ def validate(
|
||||
has_hard_error = bool(struct_hard or sem_errors)
|
||||
has_warning = bool(all_warnings)
|
||||
|
||||
# No-pin info hint — surface whenever validation has no hard errors,
|
||||
# regardless of warning count. Gating it on a pristine project (the old
|
||||
# behavior) hid the nudge from the users most likely to need it: anyone
|
||||
# actively working through warnings. Placed BEFORE the exit raise so the
|
||||
# hint is still visible under --strict (where warnings become exit 1).
|
||||
# Hard errors still suppress so error output stays focused on the blocker.
|
||||
no_pin = not (isinstance(profile_pin, str) and profile_pin.strip())
|
||||
if no_pin and not has_hard_error:
|
||||
typer.echo(
|
||||
"\nNote: no profile bound to this project. Connection will fall "
|
||||
"back to the\n"
|
||||
" globally active profile in ~/.wren/profiles.yml.\n"
|
||||
" Run `wren context set-profile <name>` to pin one explicitly."
|
||||
)
|
||||
|
||||
if has_hard_error or (strict and has_warning):
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -521,6 +563,101 @@ def instructions(
|
||||
typer.echo(content)
|
||||
|
||||
|
||||
@context_app.command(name="set-profile")
|
||||
def set_profile(
|
||||
name: Annotated[str, typer.Argument(help="Profile name to bind to this project.")],
|
||||
path: ProjectPathOpt = None,
|
||||
) -> None:
|
||||
"""Bind a connection profile to this project.
|
||||
|
||||
Writes ``profile: <name>`` and ``data_source: <profile.datasource>`` into
|
||||
``wren_project.yml``. Future CLI commands and the SDK use the bound
|
||||
profile regardless of which profile is globally active.
|
||||
"""
|
||||
from wren.context import ( # noqa: PLC0415
|
||||
discover_project_path,
|
||||
load_project_config,
|
||||
save_project_config,
|
||||
)
|
||||
from wren.profile import list_profiles # noqa: PLC0415
|
||||
|
||||
try:
|
||||
project_path = discover_project_path(path)
|
||||
except SystemExit as e:
|
||||
typer.echo(str(e), err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# discover_project_path() with explicit --path returns it un-checked, so
|
||||
# confirm the project actually exists before binding a profile to nothing.
|
||||
from wren.context import PROJECT_FILE # noqa: PLC0415
|
||||
|
||||
if not (project_path / PROJECT_FILE).exists():
|
||||
typer.echo(
|
||||
f"Error: no {PROJECT_FILE} found at {project_path}.\n"
|
||||
" Run `wren context init` to scaffold a project first.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
profiles = list_profiles()
|
||||
except Exception as exc:
|
||||
typer.echo(
|
||||
f"Error: could not read ~/.wren/profiles.yml: {exc}",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if name not in profiles:
|
||||
avail = ", ".join(sorted(profiles)) or "(none)"
|
||||
typer.echo(
|
||||
f"Error: profile '{name}' not found in ~/.wren/profiles.yml.\n"
|
||||
f" Available profiles: {avail}\n"
|
||||
f" Run `wren profile add {name} --datasource <ds>` to create it.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
new_ds = profiles[name].get("datasource")
|
||||
if not new_ds:
|
||||
typer.echo(
|
||||
f"Error: profile '{name}' has no datasource field. "
|
||||
"Edit ~/.wren/profiles.yml or recreate it via `wren profile add`.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
config = load_project_config(project_path)
|
||||
old_ds = config.get("data_source")
|
||||
config["profile"] = name
|
||||
config["data_source"] = new_ds
|
||||
try:
|
||||
save_project_config(project_path, config)
|
||||
except OSError as exc:
|
||||
typer.echo(
|
||||
f"Error: could not write {project_path / PROJECT_FILE}: {exc}",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
project_name = config.get("name") or "<unnamed>"
|
||||
typer.echo(f"✓ Bound profile '{name}' to project {project_name}")
|
||||
typer.echo(f" profile: {name}")
|
||||
if old_ds and old_ds != new_ds:
|
||||
typer.echo(f" data_source: {old_ds} -> {new_ds}")
|
||||
else:
|
||||
typer.echo(f" data_source: {new_ds}")
|
||||
|
||||
# Stale-MDL warning: if datasource changed AND a built manifest already
|
||||
# exists, it was emitted for the previous dialect and queries will break
|
||||
# against the new connection until the user rebuilds.
|
||||
if old_ds and old_ds != new_ds and (project_path / "target" / "mdl.json").exists():
|
||||
typer.echo(
|
||||
f"\n⚠ MDL was built for {old_ds}. Run `wren context build` "
|
||||
"to regenerate before querying."
|
||||
)
|
||||
|
||||
|
||||
@context_app.command()
|
||||
def upgrade(
|
||||
path: ProjectPathOpt = None,
|
||||
|
||||
@@ -214,6 +214,56 @@ def get_active_profile() -> tuple[str | None, dict]:
|
||||
return name, dict(profiles.get(name, {}))
|
||||
|
||||
|
||||
def resolve_profile_for_project(project_path: Path) -> tuple[str | None, dict]:
|
||||
"""Resolve the connection profile for a given project.
|
||||
|
||||
Resolution order:
|
||||
1. ``profile:`` field in ``<project>/wren_project.yml`` (if non-empty)
|
||||
2. Global active profile in ``~/.wren/profiles.yml``
|
||||
|
||||
Returns ``(name, profile_dict)``. Returns ``(None, {})`` if neither a
|
||||
project pin nor a global active profile is set.
|
||||
|
||||
Raises ``SystemExit`` when the project pins a profile name that does not
|
||||
exist in profiles.yml — fail loudly because the user explicitly bound
|
||||
a profile that's no longer there.
|
||||
"""
|
||||
project_yml = project_path / "wren_project.yml"
|
||||
pinned_name: str | None = None
|
||||
if project_yml.exists():
|
||||
try:
|
||||
config = yaml.safe_load(project_yml.read_text()) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
# Fail loudly: a malformed project file shouldn't silently fall
|
||||
# back to the global active profile — that risks running against
|
||||
# the wrong database.
|
||||
raise SystemExit(
|
||||
f"Error: invalid YAML in {project_yml}: {exc}\n"
|
||||
" Fix the file or run `wren context init --force` to "
|
||||
"rescaffold."
|
||||
) from exc
|
||||
if isinstance(config, dict):
|
||||
value = config.get("profile")
|
||||
if isinstance(value, str) and value.strip():
|
||||
pinned_name = value.strip()
|
||||
|
||||
if pinned_name is None:
|
||||
return get_active_profile()
|
||||
|
||||
data = _load_raw()
|
||||
profiles = data.get("profiles", {})
|
||||
if pinned_name not in profiles:
|
||||
available = ", ".join(sorted(profiles)) or "(none)"
|
||||
raise SystemExit(
|
||||
f"Error: project pins profile '{pinned_name}' but it doesn't exist "
|
||||
f"in {_PROFILES_FILE}.\n"
|
||||
f"Available profiles: {available}.\n"
|
||||
"Run `wren context set-profile <name>` to rebind, or "
|
||||
f"`wren profile add {pinned_name}` to recreate the missing profile."
|
||||
)
|
||||
return pinned_name, dict(profiles[pinned_name])
|
||||
|
||||
|
||||
def add_profile(name: str, profile: dict, *, activate: bool = False) -> None:
|
||||
"""Add or overwrite a named profile."""
|
||||
data = _load_raw()
|
||||
|
||||
@@ -145,6 +145,94 @@ def test_resolve_no_profile():
|
||||
assert conn == {}
|
||||
|
||||
|
||||
# ── resolve_profile_for_project ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_project(project_path: Path, **fields) -> None:
|
||||
"""Write a minimal wren_project.yml with the given fields."""
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
config = {
|
||||
"schema_version": 3,
|
||||
"name": "test_proj",
|
||||
"version": "1.0",
|
||||
"catalog": "wren",
|
||||
"schema": "public",
|
||||
"data_source": "duckdb",
|
||||
}
|
||||
config.update(fields)
|
||||
(project_path / "wren_project.yml").write_text(yaml.safe_dump(config))
|
||||
|
||||
|
||||
def test_resolve_profile_for_project_uses_pinned_profile(tmp_path: Path):
|
||||
profile_mod.add_profile("project_a", {"datasource": "duckdb", "path": "/a"})
|
||||
profile_mod.add_profile("project_b", {"datasource": "postgres", "host": "x"})
|
||||
profile_mod.switch_profile("project_a") # active != pinned
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_write_project(proj, profile="project_b")
|
||||
|
||||
name, prof = profile_mod.resolve_profile_for_project(proj)
|
||||
assert name == "project_b"
|
||||
assert prof["datasource"] == "postgres"
|
||||
assert prof["host"] == "x"
|
||||
|
||||
|
||||
def test_resolve_profile_for_project_falls_back_to_active(tmp_path: Path):
|
||||
profile_mod.add_profile("active_one", {"datasource": "duckdb", "path": "/x"})
|
||||
proj = tmp_path / "myproj"
|
||||
_write_project(proj) # no `profile:` field
|
||||
|
||||
name, prof = profile_mod.resolve_profile_for_project(proj)
|
||||
assert name == "active_one"
|
||||
assert prof["datasource"] == "duckdb"
|
||||
|
||||
|
||||
def test_resolve_profile_for_project_raises_when_pinned_missing(tmp_path: Path):
|
||||
profile_mod.add_profile("real", {"datasource": "duckdb"})
|
||||
proj = tmp_path / "myproj"
|
||||
_write_project(proj, profile="ghost") # ghost doesn't exist
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
profile_mod.resolve_profile_for_project(proj)
|
||||
assert "ghost" in str(exc.value)
|
||||
assert "real" in str(exc.value) # available profiles listed
|
||||
|
||||
|
||||
def test_resolve_profile_for_project_returns_empty_when_no_pin_no_active(
|
||||
tmp_path: Path,
|
||||
):
|
||||
proj = tmp_path / "myproj"
|
||||
_write_project(proj) # no profile field, no profiles.yml setup
|
||||
name, prof = profile_mod.resolve_profile_for_project(proj)
|
||||
assert name is None
|
||||
assert prof == {}
|
||||
|
||||
|
||||
def test_resolve_profile_for_project_raises_on_malformed_yaml(tmp_path: Path):
|
||||
"""A broken wren_project.yml should fail loudly, not silently fall back to
|
||||
the global active profile — the latter risks targeting the wrong DB."""
|
||||
proj = tmp_path / "myproj"
|
||||
proj.mkdir()
|
||||
(proj / "wren_project.yml").write_text("schema_version: 3\nname: [unclosed\n")
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
profile_mod.resolve_profile_for_project(proj)
|
||||
msg = str(exc.value).lower()
|
||||
assert "wren_project.yml" in msg or "yaml" in msg
|
||||
|
||||
|
||||
def test_resolve_profile_for_project_treats_empty_profile_field_as_unset(
|
||||
tmp_path: Path,
|
||||
):
|
||||
profile_mod.add_profile("active_one", {"datasource": "duckdb"})
|
||||
proj = tmp_path / "myproj"
|
||||
_write_project(proj, profile="") # explicitly empty
|
||||
|
||||
name, prof = profile_mod.resolve_profile_for_project(proj)
|
||||
# Should fall back to active, not error on empty pin
|
||||
assert name == "active_one"
|
||||
|
||||
|
||||
# ── Round-trip persistence ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Unit tests for cli._resolve_engine_profile — the project-aware profile lookup
|
||||
used by dry-plan and dry-run. Behavior is backward-compatible when a project
|
||||
has no `profile:` field (falls back to global active)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import wren.profile as profile_mod
|
||||
from wren.cli import _resolve_engine_profile
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_profiles(tmp_path, monkeypatch):
|
||||
"""Redirect all profile I/O to a temp directory."""
|
||||
profiles_file = tmp_path / "profiles.yml"
|
||||
monkeypatch.setattr(profile_mod, "_WREN_HOME", tmp_path)
|
||||
monkeypatch.setattr(profile_mod, "_PROFILES_FILE", profiles_file)
|
||||
return profiles_file
|
||||
|
||||
|
||||
def _write_project(project_dir: Path, **fields) -> Path:
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
config = {
|
||||
"schema_version": 3,
|
||||
"name": "test_proj",
|
||||
"version": "1.0",
|
||||
"catalog": "wren",
|
||||
"schema": "public",
|
||||
"data_source": "duckdb",
|
||||
}
|
||||
config.update(fields)
|
||||
(project_dir / "wren_project.yml").write_text(yaml.safe_dump(config))
|
||||
target = project_dir / "target"
|
||||
target.mkdir(exist_ok=True)
|
||||
mdl = target / "mdl.json"
|
||||
mdl.write_text("{}")
|
||||
return mdl
|
||||
|
||||
|
||||
def test_resolve_engine_profile_prefers_project_pin_when_mdl_given(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""--mdl <project>/target/mdl.json + project pins profile B → returns B."""
|
||||
profile_mod.add_profile("active_a", {"datasource": "duckdb", "path": "/a"})
|
||||
profile_mod.add_profile("pinned_b", {"datasource": "postgres", "host": "b"})
|
||||
# active is A but project pins B
|
||||
profile_mod.switch_profile("active_a")
|
||||
|
||||
mdl = _write_project(tmp_path / "myproj", profile="pinned_b")
|
||||
|
||||
name, prof = _resolve_engine_profile(str(mdl))
|
||||
assert name == "pinned_b"
|
||||
assert prof["datasource"] == "postgres"
|
||||
|
||||
|
||||
def test_resolve_engine_profile_falls_back_to_active_when_no_pin(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""No `profile:` field → falls back to global active."""
|
||||
profile_mod.add_profile("active_only", {"datasource": "duckdb"})
|
||||
mdl = _write_project(tmp_path / "myproj") # no profile field
|
||||
|
||||
name, prof = _resolve_engine_profile(str(mdl))
|
||||
assert name == "active_only"
|
||||
assert prof["datasource"] == "duckdb"
|
||||
|
||||
|
||||
def test_resolve_engine_profile_uses_cwd_when_mdl_none(tmp_path, monkeypatch):
|
||||
"""No --mdl → discover from cwd."""
|
||||
profile_mod.add_profile("via_cwd", {"datasource": "mysql", "host": "y"})
|
||||
profile_mod.add_profile("global_active", {"datasource": "postgres"})
|
||||
profile_mod.switch_profile("global_active")
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_write_project(proj, profile="via_cwd")
|
||||
monkeypatch.chdir(proj)
|
||||
|
||||
name, prof = _resolve_engine_profile(None)
|
||||
assert name == "via_cwd"
|
||||
assert prof["datasource"] == "mysql"
|
||||
|
||||
|
||||
def test_resolve_engine_profile_falls_back_when_no_project_at_cwd(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""No --mdl and cwd not in any project → global active."""
|
||||
profile_mod.add_profile("only", {"datasource": "duckdb"})
|
||||
monkeypatch.chdir(tmp_path) # tmp_path has no wren_project.yml
|
||||
|
||||
name, prof = _resolve_engine_profile(None)
|
||||
assert name == "only"
|
||||
|
||||
|
||||
def test_resolve_engine_profile_falls_back_when_mdl_not_a_file(tmp_path):
|
||||
"""--mdl is a base64 string (not a file path) → cannot resolve project,
|
||||
falls back to global active without crashing."""
|
||||
profile_mod.add_profile("only", {"datasource": "duckdb"})
|
||||
|
||||
name, prof = _resolve_engine_profile("base64stringthatisnotapath==")
|
||||
assert name == "only"
|
||||
|
||||
|
||||
def test_resolve_engine_profile_raises_when_pinned_profile_missing(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Project pins a profile that doesn't exist → SystemExit (loud failure)."""
|
||||
profile_mod.add_profile("real", {"datasource": "duckdb"})
|
||||
mdl = _write_project(tmp_path / "myproj", profile="ghost")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
_resolve_engine_profile(str(mdl))
|
||||
|
||||
|
||||
def test_resolve_engine_profile_uses_cwd_pin_when_mdl_is_base64(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""--mdl as a base64 string must NOT silently bypass cwd's project pin —
|
||||
that would re-introduce the silent-mismatch problem this PR is closing."""
|
||||
profile_mod.add_profile("active_one", {"datasource": "postgres"})
|
||||
profile_mod.add_profile("cwd_pin", {"datasource": "duckdb"})
|
||||
profile_mod.switch_profile("active_one")
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_write_project(proj, profile="cwd_pin")
|
||||
monkeypatch.chdir(proj)
|
||||
|
||||
name, prof = _resolve_engine_profile("base64stringthatisnotapath==")
|
||||
assert name == "cwd_pin"
|
||||
assert prof["datasource"] == "duckdb"
|
||||
|
||||
|
||||
def test_resolve_engine_profile_uses_cwd_pin_when_mdl_outside_project(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""--mdl pointing to a file outside any project must still let cwd's
|
||||
pin win, so users can test external MDL artifacts against the bound DB."""
|
||||
profile_mod.add_profile("active_one", {"datasource": "postgres"})
|
||||
profile_mod.add_profile("cwd_pin", {"datasource": "duckdb"})
|
||||
profile_mod.switch_profile("active_one")
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_write_project(proj, profile="cwd_pin")
|
||||
monkeypatch.chdir(proj)
|
||||
|
||||
external = tmp_path / "external.json"
|
||||
external.write_text("{}")
|
||||
|
||||
name, prof = _resolve_engine_profile(str(external))
|
||||
assert name == "cwd_pin"
|
||||
|
||||
|
||||
def test_resolve_engine_profile_walks_up_from_nonstandard_mdl_layout(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""--mdl doesn't have to sit at <project>/target/mdl.json. Anywhere
|
||||
inside the project tree should resolve via walk-up — the current
|
||||
parent.parent shortcut hard-codes a layout that's just a build default."""
|
||||
# Use distinct active vs pinned profiles so the test actually exercises
|
||||
# walk-up rather than vacuously matching whatever active happens to be.
|
||||
profile_mod.add_profile("not_this_one", {"datasource": "postgres"})
|
||||
profile_mod.add_profile("via_walk_up", {"datasource": "duckdb"})
|
||||
profile_mod.switch_profile("not_this_one")
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
proj.mkdir()
|
||||
import yaml as _yaml # noqa: PLC0415
|
||||
|
||||
(proj / "wren_project.yml").write_text(
|
||||
_yaml.safe_dump(
|
||||
{
|
||||
"schema_version": 3,
|
||||
"name": "test_proj",
|
||||
"version": "1.0",
|
||||
"catalog": "wren",
|
||||
"schema": "public",
|
||||
"data_source": "duckdb",
|
||||
"profile": "via_walk_up",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# MDL several levels deep, not at <proj>/target/mdl.json
|
||||
deep = proj / "build" / "dist" / "artifacts"
|
||||
deep.mkdir(parents=True)
|
||||
mdl = deep / "manifest.json"
|
||||
mdl.write_text("{}")
|
||||
|
||||
name, prof = _resolve_engine_profile(str(mdl))
|
||||
assert name == "via_walk_up", (
|
||||
"Walk-up didn't find wren_project.yml; resolver still relies on the "
|
||||
"parent.parent shortcut."
|
||||
)
|
||||
@@ -347,3 +347,491 @@ def test_upgrade_cli_explicit_to_version(tmp_path):
|
||||
|
||||
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
|
||||
assert config["schema_version"] == 2
|
||||
|
||||
|
||||
# ── wren context set-profile ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _isolate_profiles(home_dir: Path, monkeypatch) -> None:
|
||||
"""Redirect ~/.wren profile I/O to ``home_dir`` for the duration of a test."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
home_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(profile_mod, "_WREN_HOME", home_dir)
|
||||
monkeypatch.setattr(profile_mod, "_PROFILES_FILE", home_dir / "profiles.yml")
|
||||
|
||||
|
||||
def _invoke_ok(args):
|
||||
"""Run the CLI and assert exit_code 0. Use for setup invocations so a
|
||||
failed scaffold/init is surfaced immediately instead of masking the
|
||||
real assertion failure later in the test."""
|
||||
result = runner.invoke(app, args)
|
||||
assert result.exit_code == 0, result.output
|
||||
return result
|
||||
|
||||
|
||||
def test_set_profile_writes_profile_field(tmp_path, monkeypatch):
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("loans_local", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "loans_local", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((proj / "wren_project.yml").read_text())
|
||||
assert config["profile"] == "loans_local"
|
||||
assert config["data_source"] == "duckdb"
|
||||
|
||||
|
||||
def test_set_profile_overwrites_placeholder_data_source(tmp_path, monkeypatch):
|
||||
"""init writes `data_source: postgres` placeholder; set-profile overwrites it
|
||||
with the bound profile's datasource (no --force needed for first bind)."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("duck_one", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "duck_one", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((proj / "wren_project.yml").read_text())
|
||||
assert config["data_source"] == "duckdb"
|
||||
|
||||
|
||||
def test_set_profile_rebind_overwrites(tmp_path, monkeypatch):
|
||||
"""Re-binding from X to Y: both profile and data_source update."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("X", {"datasource": "postgres"})
|
||||
profile_mod.add_profile("Y", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
_invoke_ok(["context", "set-profile", "X", "--path", str(proj)])
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "Y", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((proj / "wren_project.yml").read_text())
|
||||
assert config["profile"] == "Y"
|
||||
assert config["data_source"] == "duckdb"
|
||||
|
||||
|
||||
def test_set_profile_errors_when_profile_not_found(tmp_path, monkeypatch):
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("real", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "ghost", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "ghost" in result.output
|
||||
assert "real" in result.output # available profiles listed in error
|
||||
|
||||
|
||||
def test_set_profile_errors_cleanly_when_list_profiles_fails(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""If list_profiles() raises (e.g. malformed profiles.yml), set-profile
|
||||
should exit cleanly with an error message — not crash with a traceback."""
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
def _broken(*_args, **_kwargs):
|
||||
raise OSError("simulated permission denied")
|
||||
|
||||
monkeypatch.setattr(profile_mod, "list_profiles", _broken)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "anything", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert not isinstance(result.exception, OSError), (
|
||||
f"OSError leaked from list_profiles(): {result.exception!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_set_profile_errors_cleanly_when_save_fails(tmp_path, monkeypatch):
|
||||
"""If save_project_config() fails (disk full / permission denied),
|
||||
set-profile should exit with a clean error rather than a traceback."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("real", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
import wren.context as context_mod # noqa: PLC0415
|
||||
|
||||
def _broken_save(*_args, **_kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(context_mod, "save_project_config", _broken_save)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "real", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert not isinstance(result.exception, OSError), (
|
||||
f"OSError leaked from save_project_config(): {result.exception!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_set_profile_errors_when_profile_has_no_datasource(tmp_path, monkeypatch):
|
||||
"""The third validation gate in set_profile — profile exists but has
|
||||
no datasource field — exits non-zero with a helpful message."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("incomplete", {}) # no datasource key
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "incomplete", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "datasource" in result.output.lower()
|
||||
|
||||
|
||||
def test_set_profile_errors_when_no_project(tmp_path, monkeypatch):
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("real", {"datasource": "duckdb"})
|
||||
|
||||
empty_dir = tmp_path / "no-project"
|
||||
empty_dir.mkdir()
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "real", "--path", str(empty_dir)]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "wren_project.yml" in result.output
|
||||
|
||||
|
||||
def test_set_profile_preserves_other_fields(tmp_path, monkeypatch):
|
||||
"""Binding doesn't touch unrelated fields (name, catalog, schema, schema_version)."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("duck", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "duck", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((proj / "wren_project.yml").read_text())
|
||||
assert config["name"] == "my_project"
|
||||
assert config["catalog"] == "wren"
|
||||
assert config["schema"] == "public"
|
||||
assert config["schema_version"] == 3
|
||||
|
||||
|
||||
def test_set_profile_preserves_custom_fields(tmp_path, monkeypatch):
|
||||
"""Unknown / custom fields in wren_project.yml must survive set-profile.
|
||||
save_project_config appends out-of-order keys at the end; this test
|
||||
locks that contract so a future shuffle of _PROJECT_FIELD_ORDER can't
|
||||
silently drop user-added metadata."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("duck", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
# Inject a custom field the CLI doesn't know about
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((proj / "wren_project.yml").read_text())
|
||||
config["tags"] = ["analytics", "experimental"]
|
||||
config["owner"] = "data-platform"
|
||||
(proj / "wren_project.yml").write_text(yaml.safe_dump(config))
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "duck", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Round-trip: custom keys survive the rewrite
|
||||
config_after = yaml.safe_load((proj / "wren_project.yml").read_text())
|
||||
assert config_after["tags"] == ["analytics", "experimental"]
|
||||
assert config_after["owner"] == "data-platform"
|
||||
# And the binding fields landed correctly
|
||||
assert config_after["profile"] == "duck"
|
||||
assert config_after["data_source"] == "duckdb"
|
||||
|
||||
|
||||
def test_set_profile_warns_about_stale_mdl_when_datasource_changes(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Re-binding to a profile with a different datasource leaves
|
||||
target/mdl.json built for the old dialect. Surface that risk so the
|
||||
user knows to rebuild before querying."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("ds_pg", {"datasource": "postgres"})
|
||||
profile_mod.add_profile("ds_duck", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
_invoke_ok(["context", "set-profile", "ds_pg", "--path", str(proj)])
|
||||
|
||||
# Simulate that the user has built MDL against the previous dialect.
|
||||
target = proj / "target"
|
||||
target.mkdir(exist_ok=True)
|
||||
(target / "mdl.json").write_text("{}")
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "ds_duck", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "wren context build" in result.output
|
||||
# Mention either the old dialect or the word 'rebuild'/'regenerate' so
|
||||
# the warning context is clear.
|
||||
msg = result.output.lower()
|
||||
assert "postgres" in msg or "rebuild" in msg or "regenerate" in msg
|
||||
|
||||
|
||||
def test_set_profile_no_stale_mdl_warning_when_datasource_unchanged(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""If datasource doesn't change on rebind, the stale-MDL warning
|
||||
shouldn't appear — there's no actual stale state."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("a", {"datasource": "duckdb"})
|
||||
profile_mod.add_profile("b", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
_invoke_ok(["context", "set-profile", "a", "--path", str(proj)])
|
||||
|
||||
target = proj / "target"
|
||||
target.mkdir(exist_ok=True)
|
||||
(target / "mdl.json").write_text("{}")
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "b", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "wren context build" not in result.output
|
||||
|
||||
|
||||
def test_set_profile_prints_summary_with_arrow_when_data_source_changes(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""When binding overwrites data_source, summary shows the transition."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("duck", {"datasource": "duckdb"})
|
||||
|
||||
proj = tmp_path / "myproj"
|
||||
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "set-profile", "duck", "--path", str(proj)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
# init wrote postgres placeholder; we're binding duck (duckdb)
|
||||
assert "postgres" in result.output
|
||||
assert "duckdb" in result.output
|
||||
|
||||
|
||||
# ── wren context validate — profile binding hint ──────────────────────────
|
||||
|
||||
|
||||
def test_validate_hints_when_no_profile_bound(tmp_path, monkeypatch):
|
||||
"""No `profile:` field → friendly info pointing to set-profile."""
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
_make_valid_project(tmp_path) # no profile field
|
||||
|
||||
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "set-profile" in result.output
|
||||
# Wording should make it clear it's a hint, not an error.
|
||||
assert "fall back" in result.output.lower() or "fallback" in result.output.lower()
|
||||
|
||||
|
||||
def test_validate_warns_when_pinned_profile_missing(tmp_path, monkeypatch):
|
||||
"""`profile: ghost` but ghost doesn't exist → warning, exit 0 without --strict."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("real", {"datasource": "postgres"})
|
||||
_make_valid_project(tmp_path)
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
|
||||
config["profile"] = "ghost"
|
||||
(tmp_path / "wren_project.yml").write_text(yaml.safe_dump(config))
|
||||
|
||||
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "ghost" in result.output
|
||||
|
||||
|
||||
def test_validate_no_profile_hint_when_correctly_bound(tmp_path, monkeypatch):
|
||||
"""`profile: real` + real exists → no profile hint noise in output."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("real", {"datasource": "postgres"})
|
||||
_make_valid_project(tmp_path)
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
|
||||
config["profile"] = "real"
|
||||
(tmp_path / "wren_project.yml").write_text(yaml.safe_dump(config))
|
||||
|
||||
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
|
||||
assert result.exit_code == 0, result.output
|
||||
# No hint text triggered when binding is correct.
|
||||
assert "set-profile" not in result.output
|
||||
|
||||
|
||||
def test_validate_handles_list_profiles_exception(tmp_path, monkeypatch):
|
||||
"""If list_profiles() raises (e.g. permission denied on profiles.yml),
|
||||
validate must surface it as a warning, not crash with a raw traceback."""
|
||||
_make_valid_project(tmp_path)
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
|
||||
config["profile"] = "anything" # triggers the binding check
|
||||
(tmp_path / "wren_project.yml").write_text(yaml.safe_dump(config))
|
||||
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
def _broken(*_args, **_kwargs):
|
||||
raise OSError("simulated permission denied")
|
||||
|
||||
monkeypatch.setattr(profile_mod, "list_profiles", _broken)
|
||||
|
||||
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
|
||||
# The binding check must catch the exception internally — letting the
|
||||
# OSError propagate would crash the CLI with a Python traceback in real
|
||||
# use (CliRunner swallows it into result.exception, but real users see
|
||||
# the traceback on stderr).
|
||||
assert not isinstance(result.exception, OSError), (
|
||||
f"OSError leaked from list_profiles(): {result.exception!r}\n"
|
||||
"Wrap the binding check in try/except so validate degrades gracefully."
|
||||
)
|
||||
# Lock the contract: warning-only path exits 0 so users can pipe / script
|
||||
# validate without a probe-failure becoming a hard failure.
|
||||
assert result.exit_code == 0, result.output
|
||||
# Validate should still surface the failure to the user somewhere visible.
|
||||
assert "permission denied" in result.output.lower() or "anything" in result.output
|
||||
|
||||
# And under --strict the same warning becomes a hard error (exit 1) — keep
|
||||
# both ends of the contract pinned so neither direction silently flips.
|
||||
strict_result = runner.invoke(
|
||||
app, ["context", "validate", "--path", str(tmp_path), "--strict"]
|
||||
)
|
||||
assert strict_result.exit_code == 1, strict_result.output
|
||||
|
||||
|
||||
def test_validate_hint_shown_even_when_warnings_present(tmp_path, monkeypatch):
|
||||
"""The no-pin hint should fire whenever the project lacks a binding,
|
||||
not only on perfectly clean projects. A project with warnings still
|
||||
benefits from the nudge — arguably more, since it's actively being
|
||||
worked on."""
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
_make_valid_project(tmp_path)
|
||||
|
||||
# Inject a synthetic semantic warning without breaking the manifest.
|
||||
# Using a real warning trigger (e.g. broken relationship) inevitably
|
||||
# also surfaces a hard error, which would mask what we're testing.
|
||||
from wren import context as context_mod # noqa: PLC0415
|
||||
|
||||
original = context_mod.validate_manifest
|
||||
|
||||
def _with_warning(*args, **kwargs):
|
||||
result = original(*args, **kwargs)
|
||||
result["warnings"] = list(result.get("warnings", [])) + [
|
||||
"synthetic warning for hint test"
|
||||
]
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(context_mod, "validate_manifest", _with_warning)
|
||||
|
||||
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
|
||||
# The hint must appear regardless of warning count — gating it on a
|
||||
# pristine project hides it from the people who most need to see it.
|
||||
assert "set-profile" in result.output
|
||||
# And the warning we injected should still be visible (sanity check
|
||||
# that we actually got into the warning-bearing code path).
|
||||
assert "synthetic warning" in result.output
|
||||
|
||||
|
||||
def test_validate_hint_suppressed_when_hard_errors(tmp_path, monkeypatch):
|
||||
"""When validation has hard errors, the no-pin hint must not pile on
|
||||
extra noise — the user should fix the real problem first."""
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
# Project missing data_source → hard error
|
||||
(tmp_path / "wren_project.yml").write_text(
|
||||
"schema_version: 2\nname: broken\n"
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
# hint must not appear when there are hard errors
|
||||
assert "set-profile" not in result.output
|
||||
|
||||
|
||||
def test_validate_strict_fails_on_missing_pinned_profile(tmp_path, monkeypatch):
|
||||
"""--strict treats the missing-pin warning as an error."""
|
||||
import wren.profile as profile_mod # noqa: PLC0415
|
||||
|
||||
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
|
||||
profile_mod.add_profile("real", {"datasource": "postgres"})
|
||||
_make_valid_project(tmp_path)
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
|
||||
config["profile"] = "ghost"
|
||||
(tmp_path / "wren_project.yml").write_text(yaml.safe_dump(config))
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["context", "validate", "--path", str(tmp_path), "--strict"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "ghost" in result.output
|
||||
|
||||
@@ -153,7 +153,51 @@ rm -rf models/example_model views/example_view
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Generate MDL from your database
|
||||
## Step 5 — Bind the profile to your project
|
||||
|
||||
```bash
|
||||
wren context set-profile <profile-name>
|
||||
```
|
||||
|
||||
Writes both `profile: <name>` and `data_source: <ds>` (taken from the profile)
|
||||
into `wren_project.yml`. Once bound, every CLI command and the SDK use this
|
||||
profile for the project — independent of which profile is **globally active**
|
||||
(`wren profile switch` elsewhere on the same machine never affects this
|
||||
project's queries).
|
||||
|
||||
### Why bind explicitly
|
||||
|
||||
Without a binding, the CLI falls back to the globally active profile from
|
||||
`~/.wren/profiles.yml`. That works for a single-project setup, but breaks the
|
||||
moment you have two projects pointing at different databases — the active
|
||||
profile inevitably becomes wrong for one of them. Binding closes that gap.
|
||||
|
||||
### When the binding mismatches
|
||||
|
||||
`set-profile` always overwrites the project's `data_source` from the profile,
|
||||
so first-bind and rebind both work the same way:
|
||||
|
||||
```bash
|
||||
$ wren context set-profile loans_local
|
||||
✓ Bound profile 'loans_local' to project loans_proj
|
||||
profile: loans_local
|
||||
data_source: postgres -> duckdb # overwritten from profile
|
||||
```
|
||||
|
||||
Re-binding to a profile with a different datasource than the project's
|
||||
current MDL was built against will succeed, but the next `wren context build`
|
||||
or query will fail with a dialect mismatch — that's the right time to
|
||||
regenerate models with `wren-generate-mdl`.
|
||||
|
||||
### Inspecting and validating
|
||||
|
||||
`wren context validate` prints a friendly hint when no profile is bound, and
|
||||
warns when the bound profile no longer exists in `~/.wren/profiles.yml`. The
|
||||
hint disappears once a valid binding is in place.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Generate MDL from your database
|
||||
|
||||
### With an AI agent (recommended)
|
||||
|
||||
@@ -231,7 +275,7 @@ wren context build
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Index memory and start querying
|
||||
## Step 7 — Index memory and start querying
|
||||
|
||||
```bash
|
||||
wren memory index
|
||||
|
||||
@@ -22,7 +22,7 @@ This installs all four skills into your agent's skill directory:
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| **wren-onboarding** | Entry point — handles install, project scaffolding, profile setup, first query |
|
||||
| **wren-onboarding** | Entry point — handles install, `.env` setup, profile creation, project scaffolding, profile binding, first query |
|
||||
| **wren-generate-mdl** | Schema discovery and MDL project generation from a connected database |
|
||||
| **wren-usage** | Day-to-day query workflow — context, recall, SQL, store results |
|
||||
| **wren-dlt-connector** | Connect SaaS APIs (HubSpot, Stripe, Salesforce, GitHub, Slack, …) into DuckDB via dlt |
|
||||
@@ -31,7 +31,7 @@ Then **start a new agent session** (skills are loaded at session start) and ask:
|
||||
|
||||
> Use the `wren-onboarding` skill to install and set up Wren AI Core.
|
||||
|
||||
The `wren-onboarding` skill drives the rest of the setup — environment checks, project scaffolding, data source connection via `.env`, and a first query — and dispatches to the other skills as needed. Skill source: [github.com/Canner/WrenAI/tree/main/skills](https://github.com/Canner/WrenAI/tree/main/skills)
|
||||
The `wren-onboarding` skill drives the rest of the setup — environment checks, `.env` configuration, connection profile creation, project scaffolding, binding the profile to the project, and a first query — and dispatches to the other skills as needed. Skill source: [github.com/Canner/WrenAI/tree/main/skills](https://github.com/Canner/WrenAI/tree/main/skills)
|
||||
|
||||
See the [Skills reference](../reference/skills.md) for what each skill does in detail.
|
||||
|
||||
|
||||
@@ -180,6 +180,14 @@ The generated `wren_project.yml` contains default values for `catalog` and `sche
|
||||
|
||||
> **Note:** `catalog` and `schema` in `wren_project.yml` define the **Wren AI Core namespace** — they have nothing to do with your database's catalog or schema. Keep the defaults (`wren` / `public`). The actual database location of each table is specified per-model in the `table_reference` section.
|
||||
|
||||
Bind the profile you just created to this project:
|
||||
|
||||
```bash
|
||||
wren context set-profile jaffle-shop
|
||||
```
|
||||
|
||||
This writes `profile: jaffle-shop` and `data_source: duckdb` into `wren_project.yml`, locking this project to its connection. Future commands (and the SDK) use the bound profile regardless of which profile is globally active — so `wren profile switch` elsewhere can't accidentally redirect this project's queries.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Generate MDL with Claude Code
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@
|
||||
"skills": [
|
||||
{
|
||||
"name": "wren-onboarding",
|
||||
"version": "2.0",
|
||||
"description": "Onboard a user to Wren Engine end-to-end. Walks the user through environment checks, project scaffolding, connection configuration via .env, and first query. Defers procedural details, per-datasource notes, and the troubleshooting playbook to docs/get_started/connect.md so the skill stays focused on agent-side rules and routing. Use when the user wants to install Wren Engine, set up a new data source connection, or bootstrap a new project from scratch.",
|
||||
"version": "2.1",
|
||||
"description": "Onboard a user to Wren Engine end-to-end. Walks the user through environment checks, .env configuration, connection profile creation, project scaffolding, binding the profile to the project, and first query. Defers procedural details, per-datasource notes, and the troubleshooting playbook to docs/core/get_started/connect.md so the skill stays focused on agent-side rules and routing. Use when the user wants to install Wren Engine, set up a new data source connection, or bootstrap a new project from scratch.",
|
||||
"tags": [
|
||||
"wren",
|
||||
"onboarding",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"wren-dlt-connector": "1.0",
|
||||
"wren-generate-mdl": "2.2",
|
||||
"wren-onboarding": "2.0",
|
||||
"wren-onboarding": "2.1",
|
||||
"wren-usage": "2.2"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Onboard a user to Wren Engine end-to-end. Walks through environmen
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: wren-engine
|
||||
version: "2.0"
|
||||
version: "2.1"
|
||||
---
|
||||
|
||||
# Wren Onboarding — Agent Workflow
|
||||
@@ -67,16 +67,15 @@ These two are the only thing Step 2 needs; ask both together so the user has a c
|
||||
|
||||
Wait for both. Don't ask for credentials.
|
||||
|
||||
## Step 2 — Project setup (batch)
|
||||
## Step 2 — Workspace + .env setup (batch)
|
||||
|
||||
Side effects: creates `~/<project>/`, installs `wren-engine[<ds>,main]`, scaffolds project files, writes an empty `.env` template.
|
||||
Side effects: creates `~/<project>/`, installs `wren-engine[<ds>,main]`, generates an empty `.env` template. The project files (`wren_project.yml` etc.) come later in Step 3.5 — at this point we only have a directory with credentials waiting to be filled.
|
||||
|
||||
Run as a batch — report each command briefly, then end with one "please fill `.env`" ask:
|
||||
|
||||
1. `mkdir -p ~/<project> && cd ~/<project>`. Refuse to overwrite an existing `wren_project.yml`.
|
||||
1. `mkdir -p ~/<project> && cd ~/<project>`.
|
||||
2. `pip install "wren-engine[<ds>,main]"`. For datasource-specific install gotchas (macOS mysql, etc.), see [`connect.md#per-datasource-setup-notes`](https://github.com/Canner/wren-engine/blob/main/docs/get_started/connect.md).
|
||||
3. `wren context init --empty` to scaffold without placeholder examples. Edit `wren_project.yml` to set `data_source: <ds>`.
|
||||
4. **Generate the `.env` template by introspecting the connector**:
|
||||
3. **Generate the `.env` template by introspecting the connector**:
|
||||
|
||||
```bash
|
||||
wren docs connection-info <ds> --format md
|
||||
@@ -94,8 +93,8 @@ Run as a batch — report each command briefly, then end with one "please fill `
|
||||
|
||||
Special encodings (BigQuery base64, Snowflake account format, Athena AWS creds, etc.) are documented in [`connect.md#per-datasource-setup-notes`](https://github.com/Canner/wren-engine/blob/main/docs/get_started/connect.md). Surface the relevant section to the user verbatim — don't paraphrase.
|
||||
|
||||
5. Add `.env` to `.gitignore` if the project is a git repo. Suggest `chmod 600 .env`.
|
||||
6. Tell the user: project is ready, `.env` is at `<path>`, please fill every value and reply **"done"**.
|
||||
4. Add `.env` to `.gitignore` if the project is a git repo. Suggest `chmod 600 .env`.
|
||||
5. Tell the user: `.env` is at `<path>`, please fill every value and reply **"done"**.
|
||||
|
||||
## Step 3 — Create the connection profile
|
||||
|
||||
@@ -118,9 +117,27 @@ wren profile add <project> --from-file /tmp/conn.yml
|
||||
|
||||
Validation runs automatically. The CLI overwrites profiles silently — there is no `--force` flag.
|
||||
|
||||
- ✓ **Success** → continue to Step 4.
|
||||
- ✓ **Success** → continue to Step 3.5.
|
||||
- ⚠ **Any warning** → consult [`connect.md#troubleshooting`](https://github.com/Canner/wren-engine/blob/main/docs/get_started/connect.md) for the exact symptom (missing secret, driver auth failure, ValidationError, unreachable host, …) and tell the user what to fix.
|
||||
|
||||
## Step 3.5 — Scaffold the project
|
||||
|
||||
```bash
|
||||
wren context init --empty
|
||||
```
|
||||
|
||||
Refuses to overwrite an existing `wren_project.yml`. Creates the project directory layout (`models/`, `views/`, `relationships.yml`, `instructions.md`, `AGENTS.md`, `queries.yml`).
|
||||
|
||||
## Step 3.6 — Bind the profile to the project
|
||||
|
||||
```bash
|
||||
wren context set-profile <project>
|
||||
```
|
||||
|
||||
Writes both `profile: <project>` and `data_source: <ds>` into `wren_project.yml` (data_source is taken from the profile we just validated, so it's guaranteed correct). Future CLI commands and the SDK resolve the connection deterministically — independent of which profile is globally active.
|
||||
|
||||
This step also future-proofs the project for multi-project setups: once the binding is recorded, switching `wren profile switch` elsewhere never breaks this project's queries.
|
||||
|
||||
## Step 4 — Generate MDL (hand off)
|
||||
|
||||
> ⚠️ The agent **must** build MDL before any data query. Queries against tables not in MDL will fail.
|
||||
|
||||
Reference in New Issue
Block a user