diff --git a/CLAUDE.md b/CLAUDE.md index 95aba4a..3800dce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ uv run poe typecheck # Run ty type checking uv run poe test # Run all tests uv run poe test-unit # Run unit tests only uv run poe test-integration # Run integration tests only +uv run poe test-e2e # Run e2e tests (real dbt execution) uv run poe check # Run lint + typecheck together ``` diff --git a/pyproject.toml b/pyproject.toml index 2fdc8a0..2585ac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ branch = true testpaths = ["tests"] markers = [ "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", + "e2e: marks tests as end-to-end tests requiring dbt execution (deselect with '-m \"not e2e\"')", ] [tool.poe.tasks] @@ -116,6 +117,7 @@ typecheck = "ty check" test = "pytest" test-unit = "pytest tests/unit" test-integration = "pytest tests/integration -m integration" +test-e2e = "pytest tests/e2e -m e2e" check = ["lint", "typecheck"] pre-commit = "pre-commit run --all-files" diff --git a/src/brix/commands/dbt/__init__.py b/src/brix/commands/dbt/__init__.py index a356390..0b8dc4c 100644 --- a/src/brix/commands/dbt/__init__.py +++ b/src/brix/commands/dbt/__init__.py @@ -1,11 +1,15 @@ """dbt command - CLI interface for dbt operations.""" +from pathlib import Path +from typing import Annotated + import click import typer from typer.core import TyperGroup from brix.commands.dbt.profile import app as profile_app -from brix.modules.dbt import run_dbt +from brix.commands.dbt.project import app as project_app +from brix.modules.dbt import CachedPathNotFoundError, load_project_cache, run_dbt, save_project_cache class DbtGroup(TyperGroup): @@ -29,7 +33,23 @@ def invoke(self, ctx: click.Context) -> None: if cmd is None and ctx.protected_args: # No matching command - pass through to dbt - exit_code = run_dbt(ctx.protected_args + ctx.args) + # Extract --project option from context params (set by option parsing) + project_param = ctx.params.get("project") + project_path: Path | None = Path(project_param) if project_param else None + + # If project path provided, save to cache + if project_path is not None: + save_project_cache(project_path) + else: + # Try to load from cache + try: + project_path = load_project_cache() + except CachedPathNotFoundError as e: + typer.echo(f"Error: {e}", err=True) + typer.echo("Please specify a valid project path with --project", err=True) + ctx.exit(1) + + exit_code = run_dbt(ctx.protected_args + ctx.args, project_path=project_path) ctx.exit(exit_code) else: super().invoke(ctx) @@ -37,22 +57,35 @@ def invoke(self, ctx: click.Context) -> None: app = typer.Typer( cls=DbtGroup, - help="Run dbt commands.", + help="Run dbt commands.\n\nCommands not matching built-in commands will be passed through to dbt CLI.", invoke_without_command=True, context_settings={"allow_extra_args": True, "ignore_unknown_options": True, "help_option_names": ["-h", "--help"]}, ) app.add_typer(profile_app, name="profile") +app.add_typer(project_app, name="project") @app.callback() -def dbt_callback(ctx: typer.Context) -> None: +def dbt_callback( + ctx: typer.Context, + project: Annotated[ + Path | None, + typer.Option( + "--project", + "-p", + help="Path to dbt project directory. Cached for subsequent commands.", + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: """Run dbt commands - custom commands or passthrough to dbt CLI.""" + # Store project path in context for use by DbtGroup.invoke() + ctx.ensure_object(dict) + ctx.obj["project_path"] = project + # If no args at all, show help if ctx.invoked_subcommand is None and not ctx.args and not ctx.protected_args: typer.echo(ctx.get_help()) - - -@app.command() -def setup() -> None: - """Setup dbt project configuration (placeholder).""" - typer.echo("dbt setup - not yet implemented") diff --git a/src/brix/commands/dbt/project.py b/src/brix/commands/dbt/project.py new file mode 100644 index 0000000..e28c9be --- /dev/null +++ b/src/brix/commands/dbt/project.py @@ -0,0 +1,656 @@ +"""Project management commands for dbt.""" + +from pathlib import Path +from typing import Annotated, Literal + +import typer + +from brix.modules.dbt.project.models import HubPackage, PackageNameError, ProjectNameError, validate_hub_package_name +from brix.modules.dbt.project.prompts import run_dbt_deps, run_interactive_edit, run_interactive_init +from brix.modules.dbt.project.service import ( + ProjectExistsError, + fetch_package_versions_parallel, + get_package_version, + init_project, +) +from brix.utils.logging import get_logger + +MaterializationType = Literal["view", "table", "ephemeral"] + +# Action types for CLI edit command +EditActionType = Literal[ + "set-name", + "set-profile", + "set-version", + "set-require-dbt-version", + "add-path", + "remove-path", + "add-hub-package", + "add-git-package", + "add-local-package", + "remove-package", + "update-package-version", +] + +# Known package mappings for short names +KNOWN_PACKAGES = { + "dbt_utils": "dbt-labs/dbt_utils", + "dbt-utils": "dbt-labs/dbt_utils", + "elementary": "elementary-data/elementary", + "codegen": "dbt-labs/codegen", + "dbt_expectations": "calogica/dbt_expectations", + "dbt-expectations": "calogica/dbt_expectations", + "audit_helper": "dbt-labs/audit_helper", + "audit-helper": "dbt-labs/audit_helper", +} + +app = typer.Typer( + help="Manage dbt projects.", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +def _resolve_package_name(short_name: str) -> str: + """Resolve short package name to full namespace/name format. + + Args: + short_name: Short or full package name + + Returns: + Full package name in namespace/name format + + Raises: + PackageNameError: If resolved name is not valid hub format + """ + resolved = KNOWN_PACKAGES.get(short_name, short_name) + validate_hub_package_name(resolved) + return resolved + + +def _build_package_list(packages: list[str] | None) -> list[HubPackage]: + """Build package list with versions from dbt Hub.""" + pkg_names = ["dbt-labs/dbt_utils"] + if packages: + for pkg in packages: + resolved = _resolve_package_name(pkg) if "/" not in pkg else pkg + validate_hub_package_name(resolved) + if resolved not in pkg_names: + pkg_names.append(resolved) + + typer.echo("Fetching package versions...") + versions = fetch_package_versions_parallel(pkg_names) + + pkg_list = [] + for pkg_name in pkg_names: + version = versions[pkg_name] + pkg_list.append(HubPackage(package=pkg_name, version=version)) + typer.echo(f" {pkg_name}: {version}") + return pkg_list + + +def _run_cli_init( + project_name: str, + profile: str, + base_dir: Path | None, + team: str | None, + packages: list[str] | None, + no_packages: bool, + materialization: MaterializationType | None, + persist_docs: bool | None, + with_example: bool | None, + run_deps: bool | None, + force: bool, +) -> None: + """Run project initialization in CLI mode.""" + logger = get_logger() + + try: + pkg_list = None if no_packages else _build_package_list(packages) + result = init_project( + project_name=project_name, + profile_name=profile, + base_dir=base_dir, + team=team, + packages=pkg_list, + materialization=materialization, + persist_docs=persist_docs or False, + with_example=with_example if with_example is not None else False, + force=force, + ) + typer.echo(f"\n{result.message}") + typer.echo("\nFiles created:") + for f in result.files_created: + typer.echo(f" {f}") + + if run_deps is True: + run_dbt_deps(result.project_path) + elif run_deps is None and not no_packages: + typer.echo(f"\nRun 'dbt deps' in {result.project_path} to install packages.") + + typer.echo("\nProject initialization complete!") + + except ProjectExistsError as e: + logger.debug("Project exists error", exc_info=e) + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + except ProjectNameError as e: + logger.debug("Project name error", exc_info=e) + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + except PackageNameError as e: + logger.debug("Package name error", exc_info=e) + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + except ValueError as e: + logger.debug("Validation error", exc_info=e) + typer.echo(f"Validation error: {e}", err=True) + raise typer.Exit(1) from None + + +@app.command() +def init( + # Basic options + project_name: Annotated[ + str | None, + typer.Option( + "--project-name", + "-n", + help="Name of the dbt project", + ), + ] = None, + base_dir: Annotated[ + Path | None, + typer.Option( + "--base-dir", + "-b", + help="Base directory for project (default: current dir, env: BRIX_DBT_PROJECT_BASE_DIR)", + envvar="BRIX_DBT_PROJECT_BASE_DIR", + ), + ] = None, + team: Annotated[ + str | None, + typer.Option( + "--team", + "-t", + help="Team subdirectory (optional)", + ), + ] = None, + # Profile options + profile: Annotated[ + str | None, + typer.Option( + "--profile", + "-p", + help="Profile name to use in dbt_project.yml", + ), + ] = None, + profile_path: Annotated[ + Path | None, + typer.Option( + "--profile-path", + help="Path to profiles.yml for validation", + envvar="BRIX_DBT_PROFILE_PATH", + ), + ] = None, + # Package options + packages: Annotated[ + list[str] | None, + typer.Option( + "--packages", + help="Additional packages to include (can specify multiple times)", + ), + ] = None, + no_packages: Annotated[ + bool, + typer.Option( + "--no-packages", + help="Skip package installation", + ), + ] = False, + # Databricks-specific options + materialization: Annotated[ + MaterializationType | None, + typer.Option( + "--materialization", + help="Default materialization (view, table, ephemeral)", + ), + ] = None, + persist_docs: Annotated[ + bool | None, + typer.Option( + "--persist-docs/--no-persist-docs", + help="Enable persist_docs for Unity Catalog", + ), + ] = None, + # Post-init options + run_deps: Annotated[ + bool | None, + typer.Option( + "--run-deps/--no-run-deps", + help="Run 'dbt deps' after project creation", + ), + ] = None, + # Example model + with_example: Annotated[ + bool | None, + typer.Option( + "--with-example/--no-example", + help="Create example model", + ), + ] = None, + # Other + force: Annotated[ + bool, + typer.Option( + "--force", + "-f", + help="Overwrite existing project", + ), + ] = False, +) -> None: + r"""Initialize a new dbt project with sensible defaults. + + Without --project-name, launches an interactive wizard. + With --project-name, runs in CLI mode with all options via flags. + + Examples: + brix dbt project init + brix dbt project init -n my_project -p default + """ + if project_name is None: + run_interactive_init(profile_path) + return + + if profile is None: + typer.echo("--profile is required in CLI mode", err=True) + raise typer.Exit(1) + + _run_cli_init( + project_name=project_name, + profile=profile, + base_dir=base_dir, + team=team, + packages=packages, + no_packages=no_packages, + materialization=materialization, + persist_docs=persist_docs, + with_example=with_example, + run_deps=run_deps, + force=force, + ) + + +def _cli_set_project_field( + project_path: Path, + field: str, + value: str | None, + required_msg: str, + success_msg: str, +) -> None: + """Handle CLI set-* actions for project fields.""" + from brix.modules.dbt.project.editor import ( + InvalidFieldError, + ProjectNotFoundError, + load_project, + save_project, + update_project_field, + ) + + if not value: + typer.echo(required_msg, err=True) + raise typer.Exit(1) + + try: + project = load_project(project_path) + project = update_project_field(project, field, value) + save_project(project, project_path) + typer.echo(success_msg.format(value=value)) + except (ProjectNotFoundError, InvalidFieldError, ProjectNameError) as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + + +def _cli_set_require_dbt_version(project_path: Path, value: str | None) -> None: + """Handle CLI set-require-dbt-version action.""" + from brix.modules.dbt.project.editor import ( + InvalidFieldError, + ProjectNotFoundError, + load_project, + save_project, + update_project_field, + ) + + try: + project = load_project(project_path) + project = update_project_field(project, "require_dbt_version", value or None) + save_project(project, project_path) + if value: + typer.echo(f"Updated require-dbt-version to '{value}'") + else: + typer.echo("Cleared require-dbt-version") + except (ProjectNotFoundError, InvalidFieldError) as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + + +def _cli_path_action( + project_path: Path, + action: str, + path_field: str | None, + path_value: str | None, + create_dir: bool | None, +) -> None: + """Handle CLI add-path/remove-path actions.""" + from brix.modules.dbt.project.editor import ( + InvalidFieldError, + ProjectNotFoundError, + load_project, + save_project, + update_path_field, + ) + + if not path_field or not path_value: + typer.echo(f"--path-field and --path are required for {action} action", err=True) + raise typer.Exit(1) + + operation = "add" if action == "add-path" else "remove" + try: + project = load_project(project_path) + project = update_path_field(project, path_field, operation, path_value) + save_project(project, project_path) + verb = "Added" if operation == "add" else "Removed" + prep = "to" if operation == "add" else "from" + typer.echo(f"{verb} '{path_value}' {prep} {path_field}") + + if operation == "add" and create_dir: + full_path = project_path.parent / path_value + if not full_path.exists(): + full_path.mkdir(parents=True, exist_ok=True) + typer.echo(f"Created directory: {full_path}") + except (ProjectNotFoundError, InvalidFieldError, ValueError) as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + + +def _cli_package_action( # noqa: C901 + project_path: Path, + action: EditActionType, + package: str | None, + package_version: str | None, + revision: str | None, + subdirectory: str | None, +) -> None: + """Handle CLI package actions.""" + from brix.modules.dbt.project.editor import ( + PackageAlreadyExistsError, + PackageNotFoundError, + add_git_package, + add_hub_package, + add_local_package, + load_packages, + remove_package, + save_packages, + update_package_version, + ) + + if action == "add-hub-package": + if not package: + typer.echo("--package is required for add-hub-package action", err=True) + raise typer.Exit(1) + try: + resolved = _resolve_package_name(package) if "/" not in package else package + validate_hub_package_name(resolved) + ver = package_version or get_package_version(resolved) + pkgs = load_packages(project_path) + pkgs = add_hub_package(pkgs, resolved, ver) + save_packages(pkgs, project_path) + typer.echo(f"Added hub package: {resolved} ({ver})") + except PackageNameError as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + except PackageAlreadyExistsError as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + + elif action == "add-git-package": + if not package or not revision: + typer.echo("--package (git URL) and --revision required for add-git-package", err=True) + raise typer.Exit(1) + try: + pkgs = load_packages(project_path) + pkgs = add_git_package(pkgs, package, revision, subdirectory) + save_packages(pkgs, project_path) + typer.echo(f"Added git package: {package} ({revision})") + except PackageAlreadyExistsError as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + + elif action == "add-local-package": + if not package: + typer.echo("--package (local path) is required for add-local-package action", err=True) + raise typer.Exit(1) + try: + pkgs = load_packages(project_path) + pkgs = add_local_package(pkgs, package) + save_packages(pkgs, project_path) + typer.echo(f"Added local package: {package}") + except PackageAlreadyExistsError as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + + elif action == "remove-package": + if not package: + typer.echo("--package is required for remove-package action", err=True) + raise typer.Exit(1) + try: + pkgs = load_packages(project_path) + pkgs = remove_package(pkgs, package) + save_packages(pkgs, project_path) + typer.echo(f"Removed package: {package}") + except PackageNotFoundError as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + + elif action == "update-package-version": + if not package or not package_version: + typer.echo("--package and --package-version required for update-package-version", err=True) + raise typer.Exit(1) + try: + pkgs = load_packages(project_path) + pkgs = update_package_version(pkgs, package, package_version) + save_packages(pkgs, project_path) + typer.echo(f"Updated {package} to version {package_version}") + except (PackageNotFoundError, ValueError) as e: + typer.echo(str(e), err=True) + raise typer.Exit(1) from None + + +def _run_cli_edit_action( + action: EditActionType, + project_path: Path, + name: str | None, + profile_name: str | None, + version: str | None, + require_dbt_version: str | None, + path_field: str | None, + path_value: str | None, + create_dir: bool | None, + package: str | None, + package_version: str | None, + revision: str | None, + subdirectory: str | None, + force: bool, +) -> None: + """Execute a CLI edit action.""" + logger = get_logger() + + if action == "set-name": + _cli_set_project_field( + project_path, + "name", + name, + "--name is required for set-name action", + "Updated project name to '{value}'", + ) + elif action == "set-profile": + _cli_set_project_field( + project_path, + "profile", + profile_name, + "--profile is required for set-profile action", + "Updated profile to '{value}'", + ) + elif action == "set-version": + _cli_set_project_field( + project_path, + "version", + version, + "--version is required for set-version action", + "Updated version to '{value}'", + ) + elif action == "set-require-dbt-version": + _cli_set_require_dbt_version(project_path, require_dbt_version) + elif action in ("add-path", "remove-path"): + _cli_path_action(project_path, action, path_field, path_value, create_dir) + else: + _cli_package_action(project_path, action, package, package_version, revision, subdirectory) + + logger.debug("Completed action: %s", action) + + +@app.command() +def edit( + # Project selection + project_path: Annotated[ + Path | None, + typer.Option( + "--project", + "-p", + help="Path to dbt_project.yml (interactive selection if not provided)", + ), + ] = None, + # Action specification + action: Annotated[ + EditActionType | None, + typer.Option( + "--action", + "-a", + help="Action to perform (interactive if not provided)", + ), + ] = None, + # Project settings + name: Annotated[ + str | None, + typer.Option("--name", help="New project name (for set-name action)"), + ] = None, + profile_name: Annotated[ + str | None, + typer.Option("--profile", help="New profile name (for set-profile action)"), + ] = None, + version: Annotated[ + str | None, + typer.Option("--version", "-v", help="New project version (for set-version action)"), + ] = None, + require_dbt_version: Annotated[ + str | None, + typer.Option("--require-dbt-version", help="dbt version constraint"), + ] = None, + # Path field operations + path_field: Annotated[ + str | None, + typer.Option( + "--path-field", + help="Path field to modify (model-paths, seed-paths, etc.)", + ), + ] = None, + path_value: Annotated[ + str | None, + typer.Option("--path", help="Path value to add/remove"), + ] = None, + create_dir: Annotated[ + bool | None, + typer.Option( + "--create-dir/--no-create-dir", + help="Create directory when adding path", + ), + ] = None, + # Package operations + package: Annotated[ + str | None, + typer.Option("--package", help="Package name (hub: org/name, git: URL, local: path)"), + ] = None, + package_version: Annotated[ + str | None, + typer.Option("--package-version", help="Package version specifier"), + ] = None, + revision: Annotated[ + str | None, + typer.Option("--revision", help="Git revision (branch, tag, commit)"), + ] = None, + subdirectory: Annotated[ + str | None, + typer.Option("--subdirectory", help="Subdirectory within git repo"), + ] = None, + # Flags + force: Annotated[ + bool, + typer.Option("--force", "-f", help="Skip confirmations"), + ] = False, +) -> None: + r"""Edit dbt project configuration. + + Without --action, launches interactive editor with project discovery. + With --action, performs the specified action non-interactively. + + Examples: + # Interactive mode with project discovery + brix dbt project edit + + # Interactive mode for specific project + brix dbt project edit -p ./my_project/dbt_project.yml + + # CLI: Update project name + brix dbt project edit -p ./proj/dbt_project.yml --action set-name --name new_name + + # CLI: Add hub package + brix dbt project edit -p ./proj/dbt_project.yml --action add-hub-package \ + --package dbt-labs/dbt_utils --package-version ">=1.0.0" + + # CLI: Add path with directory creation + brix dbt project edit -p ./proj/dbt_project.yml --action add-path \ + --path-field model-paths --path staging --create-dir + + # CLI: Remove a package + brix dbt project edit -p ./proj/dbt_project.yml --action remove-package \ + --package dbt-labs/dbt_utils + """ + if action is None: + # Interactive mode + run_interactive_edit(project_path) + return + + # CLI mode - project_path is required + if project_path is None: + typer.echo("--project is required in CLI mode", err=True) + raise typer.Exit(1) + + if not project_path.exists(): + typer.echo(f"Project file not found: {project_path}", err=True) + raise typer.Exit(1) + + _run_cli_edit_action( + action=action, + project_path=project_path, + name=name, + profile_name=profile_name, + version=version, + require_dbt_version=require_dbt_version, + path_field=path_field, + path_value=path_value, + create_dir=create_dir, + package=package, + package_version=package_version, + revision=revision, + subdirectory=subdirectory, + force=force, + ) diff --git a/src/brix/modules/dbt/__init__.py b/src/brix/modules/dbt/__init__.py index c402aef..899237f 100644 --- a/src/brix/modules/dbt/__init__.py +++ b/src/brix/modules/dbt/__init__.py @@ -1,6 +1,12 @@ """dbt module.""" -from brix.modules.dbt.passthrough import pre_dbt_hook, run_dbt +from brix.modules.dbt.passthrough import ( + CachedPathNotFoundError, + load_project_cache, + pre_dbt_hook, + run_dbt, + save_project_cache, +) from brix.modules.dbt.profile import ( DbtProfiles, OutputAlreadyExistsError, @@ -12,6 +18,7 @@ ) __all__ = [ + "CachedPathNotFoundError", "DbtProfiles", "OutputAlreadyExistsError", "OutputNotFoundError", @@ -19,6 +26,8 @@ "ProfileExistsError", "ProfileNotFoundError", "init_profile", + "load_project_cache", "pre_dbt_hook", "run_dbt", + "save_project_cache", ] diff --git a/src/brix/modules/dbt/passthrough.py b/src/brix/modules/dbt/passthrough.py index 9011bcc..9e5a84b 100644 --- a/src/brix/modules/dbt/passthrough.py +++ b/src/brix/modules/dbt/passthrough.py @@ -1,14 +1,77 @@ """dbt module - business logic for dbt operations.""" import subprocess +from pathlib import Path + +from pydantic import BaseModel, ValidationError from brix.utils.logging import get_logger +CACHE_DIR = Path.home() / ".cache" / "brix" +PROJECT_CACHE_FILE = CACHE_DIR / "dbt_project_path.json" + class DbtNotFoundError(Exception): """Raised when dbt executable cannot be found.""" +class ProjectPathCache(BaseModel): + """Cached project path for dbt passthrough.""" + + project_path: Path + + +class CachedPathNotFoundError(FileNotFoundError): + """Raised when cached project path no longer exists.""" + + +def load_project_cache() -> Path | None: + """Load cached project path. + + Returns: + Cached project path if valid, None otherwise. + + Raises: + CachedPathNotFoundError: If cached path no longer exists or is not a directory. + """ + logger = get_logger() + if not PROJECT_CACHE_FILE.exists(): + logger.debug("Project cache file not found: %s", PROJECT_CACHE_FILE) + return None + try: + cache = ProjectPathCache.model_validate_json(PROJECT_CACHE_FILE.read_text()) + except (ValidationError, OSError) as e: + logger.debug("Failed to load project cache: %s", e) + return None + + # Check if cached path still exists (outside try/except to propagate error) + if not cache.project_path.exists(): + logger.debug("Cached project path no longer exists: %s", cache.project_path) + raise CachedPathNotFoundError(f"Cached project path no longer exists: {cache.project_path}") + if not cache.project_path.is_dir(): + logger.debug("Cached project path is not a directory: %s", cache.project_path) + raise CachedPathNotFoundError(f"Cached project path is not a directory: {cache.project_path}") + + logger.debug("Loaded project cache: %s", cache.project_path) + return cache.project_path + + +def save_project_cache(project_path: Path) -> None: + """Save project path to cache. + + Converts relative paths to absolute before saving. + + Args: + project_path: The project path to cache. + """ + logger = get_logger() + absolute_path = project_path.resolve() + CACHE_DIR.mkdir(parents=True, exist_ok=True) + cache = ProjectPathCache(project_path=absolute_path) + PROJECT_CACHE_FILE.write_text(cache.model_dump_json()) + logger.debug("Project cache saved: %s", absolute_path) + + def find_dbt_executable() -> str: """Find the dbt executable path. @@ -38,29 +101,40 @@ def pre_dbt_hook() -> None: pass -def run_dbt(args: list[str]) -> int: +def run_dbt(args: list[str], project_path: Path | None = None) -> int: """Run dbt with the given arguments and return exit code. Args: args: List of arguments to pass to dbt. + project_path: Optional directory to run dbt in. Returns: - Exit code from the dbt process (1 if dbt not found). + Exit code from the dbt process (1 if dbt not found or invalid project path). """ logger = get_logger() pre_dbt_hook() + # Validate project path if provided + if project_path is not None: + if not project_path.exists(): + logger.error("Project path does not exist: %s", project_path) + return 1 + if not project_path.is_dir(): + logger.error("Project path is not a directory: %s", project_path) + return 1 + try: dbt_path = find_dbt_executable() except DbtNotFoundError as e: logger.error(str(e)) return 1 - logger.debug("Executing dbt command: %s %s", dbt_path, " ".join(args)) + cwd = project_path.resolve() if project_path else None + logger.debug("Executing dbt command: %s %s (cwd=%s)", dbt_path, " ".join(args), cwd) try: # "unsafe" passthrough is intended, we trust the user to pass valid arguments. Its their local machine. - result = subprocess.run([dbt_path, *args]) # noqa: S603 + result = subprocess.run([dbt_path, *args], cwd=cwd) # noqa: S603 except FileNotFoundError: logger.error( "dbt not found in PATH. Ensure dbt is installed and available.\n" diff --git a/src/brix/modules/dbt/profile/models.py b/src/brix/modules/dbt/profile/models.py index c78b48b..0bf926a 100644 --- a/src/brix/modules/dbt/profile/models.py +++ b/src/brix/modules/dbt/profile/models.py @@ -29,6 +29,13 @@ class DuckDbOutput(BaseModel): extensions: list[str] = Field(default_factory=list) settings: dict[str, Any] = Field(default_factory=dict) + @model_validator(mode="after") + def sync_database_with_memory_path(self) -> Self: + """Set database to 'memory' when path is ':memory:' for dbt-duckdb compatibility.""" + if self.path == ":memory:" and self.database != "memory": + self.database = "memory" + return self + # Authentication method types for Databricks DatabricksAuthType = Literal["oauth"] @@ -238,5 +245,6 @@ def to_yaml(self) -> str: import yaml # Convert to dict, handling nested models - data = {name: profile.model_dump(exclude_none=True) for name, profile in self.root.items()} + # Use by_alias=True to output 'schema' instead of 'schema_' + data = {name: profile.model_dump(exclude_none=True, by_alias=True) for name, profile in self.root.items()} return yaml.dump(data, default_flow_style=False, sort_keys=False) diff --git a/src/brix/modules/dbt/project/__init__.py b/src/brix/modules/dbt/project/__init__.py new file mode 100644 index 0000000..e48e2ac --- /dev/null +++ b/src/brix/modules/dbt/project/__init__.py @@ -0,0 +1,6 @@ +"""dbt project management module. + +Provides models, services, and prompts for managing dbt projects. +""" + +from __future__ import annotations diff --git a/src/brix/modules/dbt/project/editor.py b/src/brix/modules/dbt/project/editor.py new file mode 100644 index 0000000..4e22432 --- /dev/null +++ b/src/brix/modules/dbt/project/editor.py @@ -0,0 +1,460 @@ +"""Project editing service for dbt projects. + +Provides CRUD operations for dbt_project.yml and packages.yml with atomic save-on-change behavior. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from brix.modules.dbt.project.models import ( + DbtPackages, + DbtProject, + GitPackage, + HubPackage, + LocalPackage, + validate_project_name, +) +from brix.utils.logging import get_logger + +# Fields that can be edited via CLI +EDITABLE_FIELDS = frozenset( + { + "name", + "profile", + "version", + "require_dbt_version", + } +) + +# Path fields that are lists of strings +PATH_FIELDS = frozenset( + { + "model_paths", + "seed_paths", + "test_paths", + "macro_paths", + "snapshot_paths", + "analysis_paths", + "asset_paths", + "clean_targets", + } +) + + +class ProjectNotFoundError(Exception): + """Raised when dbt_project.yml does not exist.""" + + +class PackageNotFoundError(Exception): + """Raised when a package does not exist in packages.yml.""" + + +class PackageAlreadyExistsError(Exception): + """Raised when attempting to add a duplicate package.""" + + +class InvalidFieldError(Exception): + """Raised when attempting to edit an invalid or restricted field.""" + + +def load_project(path: Path) -> DbtProject: + """Load dbt_project.yml from disk. + + Args: + path: Path to dbt_project.yml file + + Returns: + Parsed DbtProject instance + + Raises: + ProjectNotFoundError: If file doesn't exist + ValueError: If YAML is invalid + """ + logger = get_logger() + + if not path.exists(): + msg = f"Project file not found: {path}" + raise ProjectNotFoundError(msg) + + logger.debug("Loading project from %s", path) + return DbtProject.from_file(path) + + +def save_project(project: DbtProject, path: Path) -> None: + """Validate and save dbt_project.yml to disk. + + Args: + project: DbtProject instance to save + path: Path to dbt_project.yml file + + Raises: + ValueError: If project fails validation + IOError: If file cannot be written + """ + logger = get_logger() + + # Validate by re-parsing (ensures YAML roundtrip is valid) + yaml_content = project.to_yaml() + DbtProject.from_yaml(yaml_content) + + # Ensure parent directory exists + path.parent.mkdir(parents=True, exist_ok=True) + + # Write to disk + path.write_text(yaml_content) + logger.debug("Saved project to %s", path) + + +def load_packages(project_dir: Path) -> DbtPackages: + """Load packages.yml from project directory. + + Args: + project_dir: Path to project directory (or dbt_project.yml file) + + Returns: + Parsed DbtPackages instance (empty if file doesn't exist) + """ + logger = get_logger() + + # Handle both directory and file paths + if project_dir.name == "dbt_project.yml": + project_dir = project_dir.parent + + packages_path = project_dir / "packages.yml" + + if not packages_path.exists(): + logger.debug("No packages.yml found at %s, returning empty", packages_path) + return DbtPackages(packages=[]) + + logger.debug("Loading packages from %s", packages_path) + return DbtPackages.from_file(packages_path) + + +def save_packages(packages: DbtPackages, project_dir: Path) -> None: + """Save packages.yml to project directory. + + Args: + packages: DbtPackages instance to save + project_dir: Path to project directory (or dbt_project.yml file) + + Raises: + ValueError: If packages fail validation + IOError: If file cannot be written + """ + logger = get_logger() + + # Handle both directory and file paths + if project_dir.name == "dbt_project.yml": + project_dir = project_dir.parent + + packages_path = project_dir / "packages.yml" + + # Validate by re-parsing + yaml_content = packages.to_yaml() + DbtPackages.from_yaml(yaml_content) + + # Write to disk + packages_path.write_text(yaml_content) + logger.debug("Saved packages to %s", packages_path) + + +def update_project_field( + project: DbtProject, + field: str, + value: str | None, +) -> DbtProject: + """Update a single project field. + + Args: + project: DbtProject instance + field: Field name to update + value: New value for the field + + Returns: + Updated DbtProject instance + + Raises: + InvalidFieldError: If field is not editable + ValueError: If value fails validation (e.g., invalid project name) + """ + # Normalize field name (convert dashes to underscores) + field = field.replace("-", "_") + + if field not in EDITABLE_FIELDS: + msg = f"Field '{field}' is not editable. Editable fields: {', '.join(sorted(EDITABLE_FIELDS))}" + raise InvalidFieldError(msg) + + # Special validation for project name + if field == "name" and value is not None: + validate_project_name(value) + + setattr(project, field, value) + return project + + +def update_path_field( + project: DbtProject, + field: str, + action: Literal["add", "remove", "set"], + value: str | list[str], +) -> DbtProject: + """Update a path list field (add/remove/set). + + Args: + project: DbtProject instance + field: Field name (model_paths, seed_paths, etc.) + action: Operation to perform + value: Path(s) to add/remove, or full list for "set" + + Returns: + Updated DbtProject instance + + Raises: + InvalidFieldError: If field is not a path field + ValueError: If action is invalid or path not found for remove + """ + # Normalize field name (convert dashes to underscores) + field = field.replace("-", "_") + + if field not in PATH_FIELDS: + msg = f"Field '{field}' is not a path field. Path fields: {', '.join(sorted(PATH_FIELDS))}" + raise InvalidFieldError(msg) + + current_paths: list[str] = getattr(project, field, []) + + if action == "add": + path_to_add = value if isinstance(value, str) else value[0] + if path_to_add not in current_paths: + current_paths.append(path_to_add) + elif action == "remove": + path_to_remove = value if isinstance(value, str) else value[0] + if path_to_remove not in current_paths: + msg = f"Path '{path_to_remove}' not found in {field}" + raise ValueError(msg) + current_paths.remove(path_to_remove) + elif action == "set": + current_paths = list(value) if isinstance(value, list) else [value] + else: + msg = f"Invalid action: {action}. Must be 'add', 'remove', or 'set'" + raise ValueError(msg) + + setattr(project, field, current_paths) + return project + + +def _get_package_identifier(pkg: HubPackage | GitPackage | LocalPackage) -> str: + """Get the unique identifier for a package. + + Args: + pkg: Package instance + + Returns: + Identifier string (package name, git URL, or local path) + """ + if isinstance(pkg, HubPackage): + return pkg.package + if isinstance(pkg, GitPackage): + return pkg.git + return pkg.local + + +def get_package_identifiers(packages: DbtPackages) -> list[str]: + """Get list of all package identifiers for display. + + Args: + packages: DbtPackages instance + + Returns: + List of package identifiers + """ + return [_get_package_identifier(pkg) for pkg in packages.packages] + + +def find_package_index(packages: DbtPackages, identifier: str) -> int | None: + """Find package index by identifier. + + Args: + packages: DbtPackages instance + identifier: Package name (hub), git URL, or local path + + Returns: + Index of package or None if not found + """ + for i, pkg in enumerate(packages.packages): + if _get_package_identifier(pkg) == identifier: + return i + return None + + +def has_package(packages: DbtPackages, identifier: str) -> bool: + """Check if package exists. + + Args: + packages: DbtPackages instance + identifier: Package identifier + + Returns: + True if package exists + """ + return find_package_index(packages, identifier) is not None + + +def add_hub_package( + packages: DbtPackages, + package_name: str, + version: str, +) -> DbtPackages: + """Add a hub package. + + Args: + packages: DbtPackages instance + package_name: Package name (e.g., "dbt-labs/dbt_utils") + version: Version specifier (e.g., ">=1.0.0") + + Returns: + Updated DbtPackages instance + + Raises: + PackageAlreadyExistsError: If package already exists + """ + if has_package(packages, package_name): + msg = f"Package '{package_name}' already exists" + raise PackageAlreadyExistsError(msg) + + packages.packages.append(HubPackage(package=package_name, version=version)) + return packages + + +def add_git_package( + packages: DbtPackages, + git_url: str, + revision: str, + subdirectory: str | None = None, +) -> DbtPackages: + """Add a git package. + + Args: + packages: DbtPackages instance + git_url: Git repository URL + revision: Branch, tag, or commit hash + subdirectory: Optional subdirectory within repo + + Returns: + Updated DbtPackages instance + + Raises: + PackageAlreadyExistsError: If package already exists + """ + if has_package(packages, git_url): + msg = f"Git package '{git_url}' already exists" + raise PackageAlreadyExistsError(msg) + + packages.packages.append(GitPackage(git=git_url, revision=revision, subdirectory=subdirectory)) + return packages + + +def add_local_package( + packages: DbtPackages, + local_path: str, +) -> DbtPackages: + """Add a local package. + + Args: + packages: DbtPackages instance + local_path: Local filesystem path + + Returns: + Updated DbtPackages instance + + Raises: + PackageAlreadyExistsError: If package already exists + """ + if has_package(packages, local_path): + msg = f"Local package '{local_path}' already exists" + raise PackageAlreadyExistsError(msg) + + packages.packages.append(LocalPackage(local=local_path)) + return packages + + +def remove_package( + packages: DbtPackages, + identifier: str, +) -> DbtPackages: + """Remove a package by its identifier. + + Args: + packages: DbtPackages instance + identifier: Package name, git URL, or local path + + Returns: + Updated DbtPackages instance + + Raises: + PackageNotFoundError: If package not found + """ + index = find_package_index(packages, identifier) + if index is None: + msg = f"Package '{identifier}' not found" + raise PackageNotFoundError(msg) + + packages.packages.pop(index) + return packages + + +def update_package_version( + packages: DbtPackages, + package_name: str, + new_version: str, +) -> DbtPackages: + """Update a hub package version. + + Args: + packages: DbtPackages instance + package_name: Package name (must be a hub package) + new_version: New version specifier + + Returns: + Updated DbtPackages instance + + Raises: + PackageNotFoundError: If package not found + ValueError: If package is not a hub package + """ + index = find_package_index(packages, package_name) + if index is None: + msg = f"Package '{package_name}' not found" + raise PackageNotFoundError(msg) + + pkg = packages.packages[index] + if not isinstance(pkg, HubPackage): + msg = f"Package '{package_name}' is not a hub package, cannot update version" + raise ValueError(msg) + + pkg.version = new_version + return packages + + +def get_package_display_info(packages: DbtPackages) -> list[tuple[str, str]]: + """Get package information for display. + + Args: + packages: DbtPackages instance + + Returns: + List of (identifier, type_info) tuples for display + """ + result: list[tuple[str, str]] = [] + for pkg in packages.packages: + if isinstance(pkg, HubPackage): + result.append((pkg.package, f"hub: {pkg.version}")) + elif isinstance(pkg, GitPackage): + info = f"git: {pkg.revision}" + if pkg.subdirectory: + info += f" ({pkg.subdirectory})" + result.append((pkg.git, info)) + else: + result.append((pkg.local, "local")) + return result diff --git a/src/brix/modules/dbt/project/finder.py b/src/brix/modules/dbt/project/finder.py new file mode 100644 index 0000000..103e408 --- /dev/null +++ b/src/brix/modules/dbt/project/finder.py @@ -0,0 +1,227 @@ +"""Project discovery service for dbt projects. + +Provides functions to find dbt_project.yml files in a directory tree +with interactive fuzzy selection support. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import questionary + +from brix.modules.dbt.project.models import DbtProject +from brix.utils.logging import get_logger + +# Directories to exclude from search +EXCLUDE_DIRS = frozenset( + { + ".venv", + "venv", + ".env", + "node_modules", + "dbt_packages", + "target", + ".git", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + } +) + + +def get_search_root() -> Path: + """Get the search root directory. + + Returns git repository root if in a git repo, otherwise current working directory. + + Returns: + Path to search root directory + """ + logger = get_logger() + + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], # noqa: S607 + capture_output=True, + text=True, + check=True, + ) + git_root = Path(result.stdout.strip()) + logger.debug("Using git root as search root: %s", git_root) + return git_root + except (subprocess.CalledProcessError, FileNotFoundError): + cwd = Path.cwd() + logger.debug("Not in git repo, using cwd as search root: %s", cwd) + return cwd + + +def _should_exclude(path: Path) -> bool: + """Check if a path should be excluded from search. + + Args: + path: Path to check + + Returns: + True if path should be excluded + """ + return any(part in EXCLUDE_DIRS for part in path.parts) + + +def find_dbt_projects( + root: Path | None = None, + max_depth: int = 10, +) -> list[Path]: + """Find all dbt_project.yml files under root directory. + + Args: + root: Search root (uses get_search_root() if None) + max_depth: Maximum directory depth to search (default 10) + + Returns: + List of absolute paths to dbt_project.yml files, sorted by path + """ + logger = get_logger() + search_root = root or get_search_root() + + if not search_root.exists(): + logger.warning("Search root does not exist: %s", search_root) + return [] + + projects: list[Path] = [] + + # Use glob to find all dbt_project.yml files + for project_file in search_root.glob("**/dbt_project.yml"): + # Check depth + try: + relative = project_file.relative_to(search_root) + depth = len(relative.parts) - 1 # Subtract 1 for the filename itself + if depth > max_depth: + continue + except ValueError: + continue + + # Check exclusions + if _should_exclude(project_file): + logger.debug("Excluding project in excluded directory: %s", project_file) + continue + + projects.append(project_file.resolve()) + logger.debug("Found dbt project: %s", project_file) + + # Sort by path for consistent ordering + projects.sort() + logger.debug("Found %d dbt projects", len(projects)) + + return projects + + +def _format_project_choice(project_path: Path, search_root: Path) -> str: + """Format a project path for display in selection menu. + + Args: + project_path: Absolute path to dbt_project.yml + search_root: Root directory for relative path calculation + + Returns: + Formatted string for display + """ + try: + # Get the project directory (parent of dbt_project.yml) + project_dir = project_path.parent + relative = project_dir.relative_to(search_root) + return str(relative) if str(relative) != "." else project_dir.name + except ValueError: + return str(project_path.parent) + + +def prompt_select_project( + projects: list[Path], + search_root: Path | None = None, +) -> Path | None: + """Interactive project selection with fuzzy autocomplete. + + Args: + projects: List of dbt_project.yml paths + search_root: Root directory for relative path display (uses get_search_root() if None) + + Returns: + Selected project path, or None if cancelled + """ + if not projects: + return None + + root = search_root or get_search_root() + + # Build choice mapping: display string -> actual path + choices: dict[str, Path] = {} + for project_path in projects: + display = _format_project_choice(project_path, root) + # Handle duplicate display names by appending parent info + if display in choices: + display = str(project_path.parent) + choices[display] = project_path + + # Use autocomplete for fuzzy search if many projects, otherwise select + if len(choices) > 5: + selected = questionary.autocomplete( + "Select project (type to filter):", + choices=list(choices.keys()), + match_middle=True, + ).ask() + else: + selected = questionary.select( + "Select project:", + choices=list(choices.keys()), + ).ask() + + if selected is None: + return None + + return choices.get(selected) + + +def discover_and_select_project( + root: Path | None = None, + max_depth: int = 10, +) -> tuple[Path, DbtProject] | None: + """Combined discovery and selection flow. + + Finds dbt projects in the directory tree, prompts user to select one, + and loads the selected project. + + Args: + root: Search root (uses get_search_root() if None) + max_depth: Maximum directory depth to search + + Returns: + Tuple of (project_path, loaded DbtProject) or None if cancelled/not found + """ + import typer + + search_root = root or get_search_root() + projects = find_dbt_projects(search_root, max_depth) + + if not projects: + typer.echo(f"No dbt projects found under {search_root}", err=True) + return None + + if len(projects) == 1: + # Only one project found, use it directly + project_path = projects[0] + typer.echo(f"Found project: {project_path.parent}") + else: + typer.echo(f"Found {len(projects)} dbt projects") + project_path = prompt_select_project(projects, search_root) + if project_path is None: + return None + + # Load the project + try: + project = DbtProject.from_file(project_path) + return (project_path, project) + except Exception as e: + typer.echo(f"Error loading project: {e}", err=True) + return None diff --git a/src/brix/modules/dbt/project/models.py b/src/brix/modules/dbt/project/models.py new file mode 100644 index 0000000..769a9ef --- /dev/null +++ b/src/brix/modules/dbt/project/models.py @@ -0,0 +1,313 @@ +"""Pydantic models for dbt project configuration. + +These models provide type-safe parsing and validation of dbt_project.yml +and packages.yml files. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Annotated, Any, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# Project name validation regex - must start with letter/underscore, contain only alphanumeric/underscore +PROJECT_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + +# Hub package name validation regex - must be namespace/name format +HUB_PACKAGE_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$") + + +class ProjectNameError(ValueError): + """Raised when project name is invalid.""" + + +class PackageNameError(ValueError): + """Raised when package name is invalid.""" + + +def validate_project_name(name: str) -> str: + """Validate a dbt project name. + + Args: + name: Project name to validate + + Returns: + The validated project name + + Raises: + ProjectNameError: If name doesn't match dbt requirements + """ + if not PROJECT_NAME_PATTERN.match(name): + msg = ( + f"Invalid project name: '{name}'. " + "Project name must start with a letter or underscore and contain only " + "alphanumeric characters and underscores." + ) + raise ProjectNameError(msg) + return name + + +def validate_hub_package_name(name: str) -> str: + """Validate a dbt hub package name. + + Args: + name: Package name to validate (e.g., "dbt-labs/dbt_utils") + + Returns: + The validated package name + + Raises: + PackageNameError: If name doesn't match hub package format + """ + if not HUB_PACKAGE_PATTERN.match(name): + msg = ( + f"Invalid hub package name: '{name}'. " + "Package name must be in 'namespace/package' format " + "(e.g., 'dbt-labs/dbt_utils')." + ) + raise PackageNameError(msg) + return name + + +class DbtProject(BaseModel): + """Pydantic model for dbt_project.yml configuration. + + Represents the structure and configuration options for a dbt project. + Supports YAML serialization via from_yaml() and to_yaml() methods. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + # Required fields + name: str + profile: str + + # Version fields + version: str = "1.0.0" + config_version: Literal[2] = Field(default=2, alias="config-version") + + # Path configurations + model_paths: list[str] = Field(default=["models"], alias="model-paths") + seed_paths: list[str] = Field(default=["seeds"], alias="seed-paths") + test_paths: list[str] = Field(default=["tests"], alias="test-paths") + macro_paths: list[str] = Field(default=["macros"], alias="macro-paths") + snapshot_paths: list[str] = Field(default=["snapshots"], alias="snapshot-paths") + analysis_paths: list[str] = Field(default=["analyses"], alias="analysis-paths") + asset_paths: list[str] = Field(default=["assets"], alias="asset-paths") + + # Build configuration + clean_targets: list[str] = Field(default=["target", "dbt_packages"], alias="clean-targets") + require_dbt_version: str | None = Field(default=None, alias="require-dbt-version") + + # Model defaults (optional) + models: dict[str, Any] | None = None + seeds: dict[str, Any] | None = None + vars: dict[str, Any] | None = None + + @field_validator("name", mode="after") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate project name follows dbt requirements.""" + return validate_project_name(v) + + @classmethod + def from_yaml(cls, content: str) -> DbtProject: + """Parse project configuration from YAML string. + + Args: + content: YAML string content of dbt_project.yml + + Returns: + Parsed DbtProject instance + + Raises: + ValueError: If YAML is invalid or doesn't match schema + """ + try: + data = yaml.safe_load(content) + except yaml.YAMLError as e: + msg = f"Invalid YAML: {e}" + raise ValueError(msg) from e + + if not isinstance(data, dict): + msg = "dbt_project.yml must be a YAML mapping" + raise ValueError(msg) + + return cls(**data) + + @classmethod + def from_file(cls, path: Path) -> DbtProject: + """Load project configuration from a file path. + + Args: + path: Path to dbt_project.yml file + + Returns: + Parsed DbtProject instance + + Raises: + FileNotFoundError: If file doesn't exist + ValueError: If YAML is invalid or doesn't match schema + """ + content = path.read_text() + return cls.from_yaml(content) + + def to_yaml(self) -> str: + """Serialize project configuration to YAML string. + + Returns: + YAML string representation + """ + # Convert to dict, using aliases for YAML keys + data = self.model_dump(exclude_none=True, by_alias=True) + return yaml.dump(data, default_flow_style=False, sort_keys=False) + + +# Package type models for packages.yml + + +class HubPackage(BaseModel): + """A package from the dbt Hub (hub.getdbt.com). + + Example: + - package: dbt-labs/dbt_utils + version: ">=1.0.0" + """ + + model_config = ConfigDict(extra="forbid") + + package: str + version: str + + @field_validator("package", mode="after") + @classmethod + def validate_package_name(cls, v: str) -> str: + """Validate package name follows hub format.""" + return validate_hub_package_name(v) + + +class GitPackage(BaseModel): + """A package from a Git repository. + + Example: + - git: "https://github.com/org/repo.git" + revision: main + subdirectory: "path/to/dbt_project" + """ + + model_config = ConfigDict(extra="forbid") + + git: str + revision: str + subdirectory: str | None = None + + +class LocalPackage(BaseModel): + """A package from the local filesystem. + + Example: + - local: ../shared_macros + """ + + model_config = ConfigDict(extra="forbid") + + local: str + + +# Union of all package types +Package = Annotated[HubPackage | GitPackage | LocalPackage, Field(discriminator=None)] + + +class DbtPackages(BaseModel): + """Pydantic model for packages.yml configuration. + + Represents the list of dbt packages to install. + """ + + model_config = ConfigDict(extra="forbid") + + packages: list[HubPackage | GitPackage | LocalPackage] = Field(default_factory=list) + + @classmethod + def from_yaml(cls, content: str) -> DbtPackages: + """Parse packages configuration from YAML string. + + Args: + content: YAML string content of packages.yml + + Returns: + Parsed DbtPackages instance + + Raises: + ValueError: If YAML is invalid or doesn't match schema + """ + try: + data = yaml.safe_load(content) + except yaml.YAMLError as e: + msg = f"Invalid YAML: {e}" + raise ValueError(msg) from e + + if data is None: + return cls(packages=[]) + + if not isinstance(data, dict): + msg = "packages.yml must be a YAML mapping" + raise ValueError(msg) + + return cls(**data) + + @classmethod + def from_file(cls, path: Path) -> DbtPackages: + """Load packages configuration from a file path. + + Args: + path: Path to packages.yml file + + Returns: + Parsed DbtPackages instance + + Raises: + FileNotFoundError: If file doesn't exist + ValueError: If YAML is invalid or doesn't match schema + """ + content = path.read_text() + return cls.from_yaml(content) + + def to_yaml(self) -> str: + """Serialize packages configuration to YAML string. + + Returns: + YAML string representation + """ + data = self.model_dump(exclude_none=True) + return yaml.dump(data, default_flow_style=False, sort_keys=False) + + def add_hub_package(self, package: str, version: str) -> None: + """Add a hub package to the list. + + Args: + package: Package name (e.g., "dbt-labs/dbt_utils") + version: Version specifier (e.g., ">=1.0.0") + """ + self.packages.append(HubPackage(package=package, version=version)) + + def add_git_package(self, git: str, revision: str, subdirectory: str | None = None) -> None: + """Add a git package to the list. + + Args: + git: Git repository URL + revision: Branch, tag, or commit hash + subdirectory: Optional subdirectory within repo + """ + self.packages.append(GitPackage(git=git, revision=revision, subdirectory=subdirectory)) + + def add_local_package(self, local: str) -> None: + """Add a local package to the list. + + Args: + local: Local filesystem path + """ + self.packages.append(LocalPackage(local=local)) diff --git a/src/brix/modules/dbt/project/prompts.py b/src/brix/modules/dbt/project/prompts.py new file mode 100644 index 0000000..945c4d2 --- /dev/null +++ b/src/brix/modules/dbt/project/prompts.py @@ -0,0 +1,1342 @@ +"""Interactive prompts for dbt project initialization and editing using questionary. + +Provides a wizard-style flow for creating new dbt projects with +profile selection, package configuration, and Databricks-specific options. +Also provides interactive editing for existing projects. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Literal + +import questionary +import typer + +from brix.modules.dbt.profile.models import DatabricksOutput, DbtProfiles +from brix.modules.dbt.profile.service import get_default_profile_path +from brix.modules.dbt.project.models import DbtPackages, DbtProject, HubPackage, ProjectNameError, validate_project_name +from brix.modules.dbt.project.service import ( + POPULAR_PACKAGES, + ProjectConfig, + ProjectExistsError, + fetch_package_versions_parallel, + get_package_version, + init_project, + resolve_project_path, +) + +# Materialization types +MaterializationType = Literal["view", "table", "ephemeral"] + + +def prompt_project_name() -> str | None: + """Prompt user for project name with validation. + + Returns: + Valid project name, or None if cancelled + """ + while True: + name = questionary.text( + "Project name:", + instruction="Must start with letter/underscore, alphanumeric only", + ).ask() + + if name is None: + return None + + name = name.strip() + if not name: + typer.echo("Project name cannot be empty.", err=True) + continue + + try: + validate_project_name(name) + return name + except ProjectNameError as e: + typer.echo(str(e), err=True) + + +def prompt_base_dir() -> Path | None: + """Prompt user for base directory. + + Returns: + Base directory path, or None for current directory + """ + config = ProjectConfig() + default = str(config.base_dir) if config.base_dir else "." + + env_hint = "" + if config.base_dir: + env_hint = " (from BRIX_DBT_PROJECT_BASE_DIR)" + + path_str = questionary.text( + f"Base directory{env_hint}:", + default=default, + instruction="Press Enter for current directory", + ).ask() + + if path_str is None: + return None + + path_str = path_str.strip() + if not path_str or path_str == ".": + return None + + return Path(path_str) + + +def prompt_team() -> str | None: + """Prompt user for optional team subdirectory. + + Returns: + Team name, or None if skipped + """ + team = questionary.text( + "Team (optional):", + instruction="Press Enter to skip", + ).ask() + + if team is None or not team.strip(): + return None + + return team.strip() + + +def prompt_profile_path() -> Path | None: + """Prompt user for profiles.yml location. + + Returns: + Path to profiles.yml, or None for default + """ + default_path = get_default_profile_path() + + path_str = questionary.text( + "profiles.yml location:", + default=str(default_path), + instruction="Press Enter for default location", + ).ask() + + if path_str is None: + return None + + path_str = path_str.strip() + return Path(path_str) if path_str else default_path + + +def prompt_select_profile(profiles: DbtProfiles) -> str | None: + """Prompt user to select a profile from existing profiles. + + Args: + profiles: Loaded profiles + + Returns: + Selected profile name, or None if cancelled + """ + profile_names = list(profiles.root.keys()) + if not profile_names: + typer.echo("No profiles found.", err=True) + return None + + choices = [questionary.Choice(name, value=name) for name in profile_names] + return questionary.select("Select profile:", choices=choices).ask() + + +def prompt_profile_action() -> Literal["use_existing", "create_new"] | None: + """Prompt user to use existing profile or create new. + + Returns: + Action choice, or None if cancelled + """ + choices = [ + questionary.Choice("Use existing profile", value="use_existing"), + questionary.Choice("Create new profile", value="create_new"), + ] + return questionary.select("Profile configuration:", choices=choices).ask() + + +def prompt_profile_not_found_action() -> Literal["enter_path", "create", "skip"] | None: + """Prompt user when profiles.yml not found. + + Returns: + Action choice, or None if cancelled + """ + choices = [ + questionary.Choice("Enter path to existing profiles.yml", value="enter_path"), + questionary.Choice("Create new profiles.yml", value="create"), + questionary.Choice("Skip profile setup (configure manually)", value="skip"), + ] + return questionary.select( + "No profiles.yml found at default location. What would you like to do?", + choices=choices, + ).ask() + + +def prompt_materialization() -> MaterializationType | None: + """Prompt user for default materialization. + + Returns: + Materialization type, or None if cancelled + """ + choices = [ + questionary.Choice( + "view (default) - No data stored, SQL query only. Cheaper and faster.", + value="view", + ), + questionary.Choice( + "table - Data stored physically. Better for frequently-queried models.", + value="table", + ), + questionary.Choice( + "ephemeral - Inlined as CTE. For intermediate transformations.", + value="ephemeral", + ), + ] + return questionary.select("Default materialization for models:", choices=choices).ask() + + +def prompt_persist_docs() -> bool: + """Prompt user whether to enable persist_docs for Unity Catalog. + + Returns: + True if enabled, False otherwise + """ + return ( + questionary.confirm( + "Enable Unity Catalog documentation sync? (persist_docs)", + default=False, + instruction="Pushes model/column descriptions to Unity Catalog Explorer", + ).ask() + or False + ) + + +def prompt_select_packages() -> list[str]: + """Prompt user to select additional packages. + + Returns: + List of selected package names (e.g., ["dbt-labs/dbt_utils"]) + """ + typer.echo("\n[Package Selection]") + typer.echo("dbt_utils is always included. Select additional packages:\n") + + # Build choices - dbt_utils is always selected + choices = [] + for package, description in POPULAR_PACKAGES: + if package == "dbt-labs/dbt_utils": + # dbt_utils is pre-selected and disabled + choices.append( + questionary.Choice( + f"{package} - {description}", + value=package, + checked=True, + disabled="(always included)", + ) + ) + else: + choices.append( + questionary.Choice( + f"{package} - {description}", + value=package, + checked=False, + ) + ) + + selected = questionary.checkbox( + "Select packages:", + choices=choices, + ).ask() + + if selected is None: + return ["dbt-labs/dbt_utils"] + + # Ensure dbt_utils is always included + if "dbt-labs/dbt_utils" not in selected: + selected = ["dbt-labs/dbt_utils", *selected] + + return selected + + +def prompt_with_example() -> bool: + """Prompt user whether to create example model. + + Returns: + True if example should be created + """ + return ( + questionary.confirm( + "Create example model to help you get started?", + default=True, + ).ask() + or False + ) + + +def prompt_run_deps(project_path: Path) -> bool: + """Prompt user whether to run dbt deps after project creation. + + Args: + project_path: Path to the created project + + Returns: + True if dbt deps should be run + """ + return ( + questionary.confirm( + "Run 'dbt deps' to install packages now?", + default=True, + ).ask() + or False + ) + + +def prompt_confirm_creation( + project_name: str, + project_path: Path, + profile_name: str, + packages: list[str], + materialization: str | None, + persist_docs: bool, + with_example: bool, +) -> bool: + """Show summary and confirm project creation. + + Returns: + True if user confirms + """ + typer.echo("\n" + "=" * 50) + typer.echo("Project Summary") + typer.echo("=" * 50) + typer.echo(f" Name: {project_name}") + typer.echo(f" Path: {project_path}") + typer.echo(f" Profile: {profile_name}") + typer.echo(f" Packages: {', '.join(packages)}") + if materialization: + typer.echo(f" Materialization: {materialization}") + if persist_docs: + typer.echo(" persist_docs: enabled") + if with_example: + typer.echo(" Example model: yes") + typer.echo("=" * 50 + "\n") + + return ( + questionary.confirm( + "Create project with these settings?", + default=True, + ).ask() + or False + ) + + +def _detect_profile_type(profiles: DbtProfiles, profile_name: str) -> str | None: + """Detect the adapter type for a profile. + + Args: + profiles: Loaded profiles + profile_name: Name of the profile + + Returns: + Adapter type ("databricks", "duckdb", etc.) or None + """ + if profile_name not in profiles.root: + return None + + profile = profiles.root[profile_name] + target_name = profile.target + if target_name not in profile.outputs: + return None + + output = profile.outputs[target_name] + if isinstance(output, DatabricksOutput): + return "databricks" + return getattr(output, "type", None) + + +def run_dbt_deps(project_path: Path) -> bool: + """Run dbt deps in the project directory. + + Args: + project_path: Path to the project + + Returns: + True if successful + """ + typer.echo("\nRunning 'dbt deps'...") + try: + # S607: Using partial path intentionally to use user's dbt installation + result = subprocess.run( + ["dbt", "deps"], # noqa: S607 + cwd=project_path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + typer.echo(result.stdout) + typer.echo("Packages installed successfully!") + return True + typer.echo(result.stderr, err=True) + typer.echo("Failed to install packages. Run 'dbt deps' manually.", err=True) + return False + except FileNotFoundError: + typer.echo("dbt command not found. Install dbt and run 'dbt deps' manually.", err=True) + return False + + +def _handle_existing_profiles(profiles: DbtProfiles) -> str | None: + """Handle profile selection when profiles.yml exists with profiles.""" + action = prompt_profile_action() + if action is None: + return None + if action == "use_existing": + return prompt_select_profile(profiles) + typer.echo("\nUse 'brix dbt profile edit' to create a new profile first.") + typer.echo("Then run this wizard again.") + return None + + +def _handle_no_profiles(project_name: str) -> tuple[DbtProfiles | None, str | None]: + """Handle profile setup when no profiles.yml found.""" + action = prompt_profile_not_found_action() + if action is None: + return None, None + + if action == "enter_path": + custom_path = prompt_profile_path() + if custom_path and custom_path.exists(): + try: + profiles = DbtProfiles.from_file(custom_path) + if profiles and profiles.root: + selected = prompt_select_profile(profiles) + return profiles, selected + except Exception as e: + typer.echo(f"Error parsing profiles.yml: {e}", err=True) + else: + typer.echo("File not found.", err=True) + return None, None + + if action == "create": + typer.echo("\nUse 'brix dbt profile init' to create profiles.yml first.") + typer.echo("Then run this wizard again.") + return None, None + + # Skip - use project name as profile + typer.echo(f"\nSkipping profile setup. Using '{project_name}' as profile name.") + typer.echo("Remember to configure this profile in profiles.yml manually.") + return None, project_name + + +def _get_databricks_options( + profiles: DbtProfiles | None, profile_name: str | None +) -> tuple[MaterializationType | None, bool]: + """Get Databricks-specific options if applicable.""" + if not profiles or not profile_name: + return None, False + + adapter_type = _detect_profile_type(profiles, profile_name) + if adapter_type != "databricks": + return None, False + + typer.echo("\n[Databricks Configuration]") + materialization = prompt_materialization() + if materialization is None: + return None, False + persist_docs = prompt_persist_docs() + return materialization, persist_docs + + +def _create_project( + project_name: str, + base_dir: Path | None, + team: str | None, + selected_profile: str | None, + selected_packages: list[str], + materialization: MaterializationType | None, + persist_docs: bool, + with_example: bool, +) -> None: + """Create the project with all settings.""" + typer.echo("\nFetching package versions...") + versions = fetch_package_versions_parallel(selected_packages) + packages = [] + for pkg_name in selected_packages: + version = versions[pkg_name] + packages.append(HubPackage(package=pkg_name, version=version)) + typer.echo(f" {pkg_name}: {version}") + + try: + result = init_project( + project_name=project_name, + profile_name=selected_profile or project_name, + base_dir=base_dir, + team=team, + packages=packages, + materialization=materialization, + persist_docs=persist_docs, + with_example=with_example, + force=True, + ) + typer.echo(f"\n{result.message}") + typer.echo("\nFiles created:") + for f in result.files_created: + typer.echo(f" {f}") + + if prompt_run_deps(result.project_path): + run_dbt_deps(result.project_path) + else: + typer.echo(f"\nRemember to run 'dbt deps' in {result.project_path} to install packages.") + + typer.echo("\nProject initialization complete!") + typer.echo("Next steps:") + typer.echo(f" cd {result.project_path}") + typer.echo(" dbt debug # Test connection") + typer.echo(" dbt run # Run your models") + except ProjectExistsError as e: + typer.echo(str(e), err=True) + + +def run_interactive_init(profile_path: Path | None = None) -> None: # noqa: C901 + """Run the interactive project initialization wizard.""" + typer.echo("\n[dbt Project Initialization Wizard]\n") + + project_name = prompt_project_name() + if project_name is None: + typer.echo("Cancelled.") + return + + base_dir = prompt_base_dir() + team = prompt_team() + + project_path = resolve_project_path(project_name, base_dir, team) + if (project_path / "dbt_project.yml").exists(): + typer.echo(f"\nProject already exists at {project_path}") + if not questionary.confirm("Overwrite existing project?", default=False).ask(): + typer.echo("Cancelled.") + return + + # Profile setup + profiles: DbtProfiles | None = None + selected_profile: str | None = None + effective_profile_path = profile_path or get_default_profile_path() + + if effective_profile_path.exists(): + typer.echo(f"\nFound profiles.yml at {effective_profile_path}") + try: + profiles = DbtProfiles.from_file(effective_profile_path) + except Exception as e: + typer.echo(f"Warning: Could not parse profiles.yml: {e}", err=True) + + if profiles and profiles.root: + selected_profile = _handle_existing_profiles(profiles) + if selected_profile is None: + typer.echo("Cancelled.") + return + else: + profiles, selected_profile = _handle_no_profiles(project_name) + if selected_profile is None and profiles is None: + return + + materialization, persist_docs = _get_databricks_options(profiles, selected_profile) + if materialization is None and profiles and selected_profile: + adapter_type = _detect_profile_type(profiles, selected_profile) + if adapter_type == "databricks": + typer.echo("Cancelled.") + return + + selected_packages = prompt_select_packages() + with_example = prompt_with_example() + + if not prompt_confirm_creation( + project_name=project_name, + project_path=project_path, + profile_name=selected_profile or project_name, + packages=selected_packages, + materialization=materialization, + persist_docs=persist_docs, + with_example=with_example, + ): + typer.echo("Cancelled.") + return + + _create_project( + project_name, + base_dir, + team, + selected_profile, + selected_packages, + materialization, + persist_docs, + with_example, + ) + + +# ============================================================================= +# Project Edit Prompts +# ============================================================================= + +# Action types for edit main menu +EditMainAction = Literal[ + "edit_settings", + "manage_packages", + "edit_paths", + "exit", +] + +# Action types for settings submenu +SettingsAction = Literal["name", "profile", "version", "require_dbt_version", "back"] + +# Action types for packages submenu +PackageAction = Literal[ + "add_hub", + "add_git", + "add_local", + "remove", + "update_version", + "back", +] + +# Action types for paths submenu +PathAction = Literal[ + "model_paths", + "seed_paths", + "test_paths", + "macro_paths", + "snapshot_paths", + "analysis_paths", + "asset_paths", + "clean_targets", + "back", +] + +# Action types for path editing +PathEditAction = Literal["add", "remove", "view", "back"] + + +def prompt_edit_main_action() -> EditMainAction: + """Prompt user for main edit menu action. + + Returns: + Selected action + """ + choices = [ + questionary.Choice("Edit project settings", value="edit_settings"), + questionary.Choice("Manage packages", value="manage_packages"), + questionary.Choice("Edit path configurations", value="edit_paths"), + questionary.Choice("Exit", value="exit"), + ] + result = questionary.select("What would you like to do?", choices=choices).ask() + if result is None: + return "exit" + return result + + +def prompt_settings_action() -> SettingsAction: + """Prompt user for settings submenu action. + + Returns: + Selected action + """ + choices = [ + questionary.Choice("Edit project name", value="name"), + questionary.Choice("Edit profile name", value="profile"), + questionary.Choice("Edit version", value="version"), + questionary.Choice("Edit require-dbt-version", value="require_dbt_version"), + questionary.Choice("Back to main menu", value="back"), + ] + result = questionary.select("What would you like to edit?", choices=choices).ask() + if result is None: + return "back" + return result + + +def prompt_package_action() -> PackageAction: + """Prompt user for package submenu action. + + Returns: + Selected action + """ + choices = [ + questionary.Choice("Add hub package", value="add_hub"), + questionary.Choice("Add git package", value="add_git"), + questionary.Choice("Add local package", value="add_local"), + questionary.Choice("Remove package", value="remove"), + questionary.Choice("Update package version", value="update_version"), + questionary.Choice("Back to main menu", value="back"), + ] + result = questionary.select("What would you like to do?", choices=choices).ask() + if result is None: + return "back" + return result + + +def prompt_path_field_action() -> PathAction: + """Prompt user for path field selection. + + Returns: + Selected path field or back + """ + choices = [ + questionary.Choice("model-paths", value="model_paths"), + questionary.Choice("seed-paths", value="seed_paths"), + questionary.Choice("test-paths", value="test_paths"), + questionary.Choice("macro-paths", value="macro_paths"), + questionary.Choice("snapshot-paths", value="snapshot_paths"), + questionary.Choice("analysis-paths", value="analysis_paths"), + questionary.Choice("asset-paths", value="asset_paths"), + questionary.Choice("clean-targets", value="clean_targets"), + questionary.Choice("Back to main menu", value="back"), + ] + result = questionary.select("Select path field to edit:", choices=choices).ask() + if result is None: + return "back" + return result + + +def prompt_path_edit_action(field_name: str, current_paths: list[str]) -> PathEditAction: + """Prompt user for path edit action. + + Args: + field_name: Name of the path field + current_paths: Current paths in the field + + Returns: + Selected action + """ + typer.echo(f"\nCurrent {field_name}: {', '.join(current_paths) if current_paths else '(none)'}") + + choices = [ + questionary.Choice("Add path", value="add"), + questionary.Choice("Remove path", value="remove"), + questionary.Choice("View current paths", value="view"), + questionary.Choice("Back", value="back"), + ] + result = questionary.select("What would you like to do?", choices=choices).ask() + if result is None: + return "back" + return result + + +def prompt_edit_project_name(current: str) -> str | None: + """Prompt for new project name with validation. + + Args: + current: Current project name + + Returns: + New project name or None if cancelled + """ + while True: + name = questionary.text( + "Enter new project name:", + default=current, + instruction="Must start with letter/underscore, alphanumeric only", + ).ask() + + if name is None: + return None + + name = name.strip() + if not name: + typer.echo("Project name cannot be empty.", err=True) + continue + + try: + validate_project_name(name) + return name + except ProjectNameError as e: + typer.echo(str(e), err=True) + + +def prompt_edit_profile_name(current: str) -> str | None: + """Prompt for new profile name. + + Args: + current: Current profile name + + Returns: + New profile name or None if cancelled + """ + return questionary.text("Enter new profile name:", default=current).ask() + + +def prompt_edit_version(current: str) -> str | None: + """Prompt for new version. + + Args: + current: Current version + + Returns: + New version or None if cancelled + """ + return questionary.text("Enter new version:", default=current).ask() + + +def prompt_edit_require_dbt_version(current: str | None) -> str | None: + """Prompt for new require-dbt-version. + + Args: + current: Current constraint (may be None) + + Returns: + New constraint or None if cancelled/cleared + """ + result = questionary.text( + "Enter dbt version constraint (empty to clear):", + default=current or "", + instruction="e.g., >=1.0.0,<2.0.0", + ).ask() + + if result is None: + return current # Cancelled, keep current + return result.strip() or None + + +def prompt_add_hub_package_details() -> tuple[str, str] | None: + """Prompt for hub package details. + + Returns: + Tuple of (package_name, version) or None if cancelled + """ + # Offer popular packages or custom entry + choices = [questionary.Choice(f"{pkg} - {desc}", value=pkg) for pkg, desc in POPULAR_PACKAGES] + choices.append(questionary.Choice("Enter custom package name", value="_custom_")) + + selected = questionary.select("Select package:", choices=choices).ask() + if selected is None: + return None + + if selected == "_custom_": + package_name = questionary.text( + "Enter package name:", + instruction="e.g., dbt-labs/dbt_utils", + ).ask() + if not package_name: + return None + else: + package_name = selected + + # Fetch version + typer.echo(f"Fetching latest version for {package_name}...") + version = get_package_version(package_name) + typer.echo(f" Found version: {version}") + + # Allow override + custom_version = questionary.text( + "Version (press Enter to accept):", + default=version, + ).ask() + + if custom_version is None: + return None + + return (package_name, custom_version) + + +def prompt_add_git_package_details() -> tuple[str, str, str | None] | None: + """Prompt for git package details. + + Returns: + Tuple of (git_url, revision, subdirectory) or None if cancelled + """ + git_url = questionary.text( + "Enter git URL:", + instruction="e.g., https://github.com/org/repo.git", + ).ask() + if not git_url: + return None + + revision = questionary.text( + "Enter revision:", + default="main", + instruction="Branch, tag, or commit hash", + ).ask() + if not revision: + return None + + subdirectory = questionary.text( + "Enter subdirectory (optional):", + instruction="Leave empty if package is at repo root", + ).ask() + + if subdirectory is None: + return None + + return (git_url, revision, subdirectory.strip() or None) + + +def prompt_add_local_package_path() -> str | None: + """Prompt for local package path. + + Returns: + Local path or None if cancelled + """ + return questionary.text( + "Enter local path:", + instruction="e.g., ../shared_macros", + ).ask() + + +def prompt_select_package(packages: DbtPackages) -> str | None: + """Prompt user to select a package. + + Args: + packages: DbtPackages instance + + Returns: + Selected package identifier or None if cancelled + """ + from brix.modules.dbt.project.editor import get_package_display_info + + if not packages.packages: + typer.echo("No packages configured.", err=True) + return None + + display_info = get_package_display_info(packages) + choices = [questionary.Choice(f"{ident} ({info})", value=ident) for ident, info in display_info] + + return questionary.select("Select package:", choices=choices).ask() + + +def prompt_new_package_version(current: str) -> str | None: + """Prompt for new package version. + + Args: + current: Current version + + Returns: + New version or None if cancelled + """ + return questionary.text("Enter new version:", default=current).ask() + + +def prompt_add_path(field_name: str) -> str | None: + """Prompt to add a path. + + Args: + field_name: Name of the path field + + Returns: + Path to add or None if cancelled + """ + return questionary.text(f"Enter path to add to {field_name}:").ask() + + +def prompt_remove_path(current_paths: list[str]) -> str | None: + """Prompt to select a path to remove. + + Args: + current_paths: Current paths + + Returns: + Path to remove or None if cancelled + """ + if not current_paths: + typer.echo("No paths to remove.", err=True) + return None + + return questionary.select("Select path to remove:", choices=current_paths).ask() + + +def prompt_create_directory(path: Path) -> bool: + """Ask user if they want to create a directory. + + Args: + path: Directory path + + Returns: + True if user wants to create it + """ + return ( + questionary.confirm( + f"Directory '{path}' does not exist. Create it?", + default=True, + ).ask() + or False + ) + + +def prompt_confirm_delete(item_description: str) -> bool: + """Prompt user to confirm deletion. + + Args: + item_description: Description of item being deleted + + Returns: + True if confirmed + """ + result = questionary.confirm(f"Remove {item_description}?", default=False).ask() + return result is True + + +def _display_project_status(project: DbtProject, packages: DbtPackages, project_path: Path) -> None: + """Display current project configuration. + + Args: + project: DbtProject instance + packages: DbtPackages instance + project_path: Path to dbt_project.yml + """ + typer.echo(f"\n[Editing: {project_path.parent}]") + typer.echo(f" name: {project.name}") + typer.echo(f" profile: {project.profile}") + typer.echo(f" version: {project.version}") + if project.require_dbt_version: + typer.echo(f" require-dbt-version: {project.require_dbt_version}") + typer.echo(f" packages: {len(packages.packages)}") + + +def _handle_settings_action( + action: SettingsAction, + project: DbtProject, + project_path: Path, +) -> DbtProject: + """Handle settings menu action. + + Args: + action: Selected action + project: Current project + project_path: Path to dbt_project.yml + + Returns: + Updated project + """ + from brix.modules.dbt.project.editor import save_project, update_project_field + + if action == "back": + return project + + # Handle require_dbt_version separately since it can be None + if action == "require_dbt_version": + new_value = prompt_edit_require_dbt_version(project.require_dbt_version) + if new_value != project.require_dbt_version: + try: + project = update_project_field(project, action, new_value) + save_project(project, project_path) + typer.echo("Updated require-dbt-version") + except Exception as e: + typer.echo(f"Error: {e}", err=True) + return project + + # Handle other string fields + field_values: dict[str, str] = { + "name": project.name, + "profile": project.profile, + "version": project.version, + } + field_prompts = { + "name": prompt_edit_project_name, + "profile": prompt_edit_profile_name, + "version": prompt_edit_version, + } + + if action not in field_values: + return project + + current_value = field_values[action] + new_value = field_prompts[action](current_value) + + if new_value is not None and new_value != current_value: + try: + project = update_project_field(project, action, new_value) + save_project(project, project_path) + typer.echo(f"Updated {action.replace('_', '-')}") + except Exception as e: + typer.echo(f"Error: {e}", err=True) + + return project + + +def _handle_package_action( # noqa: C901 + action: PackageAction, + packages: DbtPackages, + project_path: Path, +) -> DbtPackages: + """Handle package menu action. + + Args: + action: Selected action + packages: Current packages + project_path: Path to dbt_project.yml (for directory) + + Returns: + Updated packages + """ + from brix.modules.dbt.project.editor import ( + PackageAlreadyExistsError, + PackageNotFoundError, + add_git_package, + add_hub_package, + add_local_package, + remove_package, + save_packages, + update_package_version, + ) + + if action == "add_hub": + details = prompt_add_hub_package_details() + if details: + package_name, version = details + try: + packages = add_hub_package(packages, package_name, version) + save_packages(packages, project_path) + typer.echo(f"Added package: {package_name} ({version})") + except PackageAlreadyExistsError as e: + typer.echo(str(e), err=True) + + elif action == "add_git": + details = prompt_add_git_package_details() + if details: + git_url, revision, subdirectory = details + try: + packages = add_git_package(packages, git_url, revision, subdirectory) + save_packages(packages, project_path) + typer.echo(f"Added git package: {git_url}") + except PackageAlreadyExistsError as e: + typer.echo(str(e), err=True) + + elif action == "add_local": + local_path = prompt_add_local_package_path() + if local_path: + try: + packages = add_local_package(packages, local_path) + save_packages(packages, project_path) + typer.echo(f"Added local package: {local_path}") + except PackageAlreadyExistsError as e: + typer.echo(str(e), err=True) + + elif action == "remove": + identifier = prompt_select_package(packages) + if identifier and prompt_confirm_delete(f"package '{identifier}'"): + try: + packages = remove_package(packages, identifier) + save_packages(packages, project_path) + typer.echo(f"Removed package: {identifier}") + except PackageNotFoundError as e: + typer.echo(str(e), err=True) + + elif action == "update_version": + identifier = prompt_select_package(packages) + if identifier: + # Find current version + from brix.modules.dbt.project.editor import find_package_index + from brix.modules.dbt.project.models import HubPackage as HubPkg + + idx = find_package_index(packages, identifier) + if idx is not None: + pkg = packages.packages[idx] + if isinstance(pkg, HubPkg): + new_version = prompt_new_package_version(pkg.version) + if new_version and new_version != pkg.version: + try: + packages = update_package_version(packages, identifier, new_version) + save_packages(packages, project_path) + typer.echo(f"Updated {identifier} to {new_version}") + except (PackageNotFoundError, ValueError) as e: + typer.echo(str(e), err=True) + else: + typer.echo("Can only update version for hub packages.", err=True) + + return packages + + +def _handle_path_action( # noqa: C901 + field: PathAction, + project: DbtProject, + project_path: Path, +) -> DbtProject: + """Handle path field editing. + + Args: + field: Path field to edit + project: Current project + project_path: Path to dbt_project.yml + + Returns: + Updated project + """ + from brix.modules.dbt.project.editor import save_project, update_path_field + + if field == "back": + return project + + current_paths: list[str] = getattr(project, field, []) + + while True: + action = prompt_path_edit_action(field.replace("_", "-"), current_paths) + + if action == "back": + break + + if action == "view": + if current_paths: + typer.echo(f"\n{field.replace('_', '-')}:") + for p in current_paths: + typer.echo(f" - {p}") + else: + typer.echo(f"\n{field.replace('_', '-')}: (none)") + continue + + if action == "add": + new_path = prompt_add_path(field.replace("_", "-")) + if new_path: + try: + project = update_path_field(project, field, "add", new_path) + save_project(project, project_path) + current_paths = getattr(project, field, []) + typer.echo(f"Added '{new_path}' to {field.replace('_', '-')}") + + # Offer to create directory + full_path = project_path.parent / new_path + if not full_path.exists() and prompt_create_directory(full_path): + full_path.mkdir(parents=True, exist_ok=True) + typer.echo(f"Created directory: {full_path}") + except Exception as e: + typer.echo(f"Error: {e}", err=True) + + elif action == "remove": + path_to_remove = prompt_remove_path(current_paths) + if path_to_remove: + try: + project = update_path_field(project, field, "remove", path_to_remove) + save_project(project, project_path) + current_paths = getattr(project, field, []) + typer.echo(f"Removed '{path_to_remove}' from {field.replace('_', '-')}") + except Exception as e: + typer.echo(f"Error: {e}", err=True) + + return project + + +def _edit_settings_loop(project: DbtProject, project_path: Path) -> DbtProject: + """Settings editing submenu loop. + + Args: + project: Current project + project_path: Path to dbt_project.yml + + Returns: + Updated project + """ + while True: + typer.echo(f"\n[Project Settings: {project.name}]") + typer.echo(f" name: {project.name}") + typer.echo(f" profile: {project.profile}") + typer.echo(f" version: {project.version}") + typer.echo(f" require-dbt-version: {project.require_dbt_version or '(not set)'}") + + action = prompt_settings_action() + if action == "back": + break + + project = _handle_settings_action(action, project, project_path) + + return project + + +def _edit_packages_loop(packages: DbtPackages, project_path: Path) -> DbtPackages: + """Packages editing submenu loop. + + Args: + packages: Current packages + project_path: Path to dbt_project.yml + + Returns: + Updated packages + """ + from brix.modules.dbt.project.editor import get_package_display_info + + while True: + typer.echo("\n[Packages]") + if packages.packages: + for ident, info in get_package_display_info(packages): + typer.echo(f" - {ident} ({info})") + else: + typer.echo(" (no packages)") + + action = prompt_package_action() + if action == "back": + break + + packages = _handle_package_action(action, packages, project_path) + + return packages + + +def _edit_paths_loop(project: DbtProject, project_path: Path) -> DbtProject: + """Path configurations editing submenu loop. + + Args: + project: Current project + project_path: Path to dbt_project.yml + + Returns: + Updated project + """ + while True: + typer.echo("\n[Path Configurations]") + typer.echo(f" model-paths: {', '.join(project.model_paths)}") + typer.echo(f" seed-paths: {', '.join(project.seed_paths)}") + typer.echo(f" test-paths: {', '.join(project.test_paths)}") + typer.echo(f" macro-paths: {', '.join(project.macro_paths)}") + typer.echo(f" snapshot-paths: {', '.join(project.snapshot_paths)}") + typer.echo(f" analysis-paths: {', '.join(project.analysis_paths)}") + typer.echo(f" asset-paths: {', '.join(project.asset_paths)}") + typer.echo(f" clean-targets: {', '.join(project.clean_targets)}") + + field = prompt_path_field_action() + if field == "back": + break + + project = _handle_path_action(field, project, project_path) + + return project + + +def run_interactive_edit(project_path: Path | None = None) -> None: + """Run the interactive project editor. + + Main entry point for interactive project editing with nested loops. + + Args: + project_path: Path to dbt_project.yml, discovers project if None + """ + from brix.modules.dbt.project.editor import load_packages, load_project + from brix.modules.dbt.project.finder import discover_and_select_project + + # Discover or use provided project + if project_path is None: + result = discover_and_select_project() + if result is None: + return + project_path, project = result + else: + try: + project = load_project(project_path) + except Exception as e: + typer.echo(f"Error loading project: {e}", err=True) + return + + # Load packages + packages = load_packages(project_path) + + typer.echo(f"Editing project at: {project_path.parent}") + + try: + while True: + _display_project_status(project, packages, project_path) + action = prompt_edit_main_action() + + if action == "exit": + typer.echo("Goodbye!") + break + + if action == "edit_settings": + project = _edit_settings_loop(project, project_path) + elif action == "manage_packages": + packages = _edit_packages_loop(packages, project_path) + elif action == "edit_paths": + project = _edit_paths_loop(project, project_path) + + except KeyboardInterrupt: + typer.echo("\nExiting...") diff --git a/src/brix/modules/dbt/project/service.py b/src/brix/modules/dbt/project/service.py new file mode 100644 index 0000000..45bc728 --- /dev/null +++ b/src/brix/modules/dbt/project/service.py @@ -0,0 +1,366 @@ +"""Project management service for dbt projects. + +Handles project initialization, path resolution, and package version fetching. +""" + +from __future__ import annotations + +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from pydantic_settings import BaseSettings, SettingsConfigDict + +from brix.modules.dbt.project.models import ( + DbtPackages, + DbtProject, + HubPackage, + validate_project_name, +) +from brix.templates import get_template +from brix.utils.logging import get_logger + +# Default fallback versions if API fetch fails +DEFAULT_PACKAGE_VERSIONS: dict[str, str] = { + "dbt-labs/dbt_utils": ">=1.0.0", + "elementary-data/elementary": ">=0.13.0", + "dbt-labs/codegen": ">=0.12.0", + "calogica/dbt_expectations": ">=0.10.0", + "dbt-labs/audit_helper": ">=0.9.0", +} + +# Popular packages to offer in the wizard +POPULAR_PACKAGES: list[tuple[str, str]] = [ + ("dbt-labs/dbt_utils", "Common utility macros and tests"), + ("elementary-data/elementary", "Data observability and quality monitoring"), + ("dbt-labs/codegen", "Code generation helpers"), + ("calogica/dbt_expectations", "Great Expectations-style tests"), + ("dbt-labs/audit_helper", "Data auditing utilities"), +] + + +class ProjectConfig(BaseSettings): + """Project configuration from environment variables. + + Environment variables: + BRIX_DBT_PROJECT_BASE_DIR: Default base directory for projects + """ + + model_config = SettingsConfigDict( + env_prefix="BRIX_DBT_PROJECT_", + case_sensitive=False, + ) + + base_dir: Path | None = None + + +class ProjectExistsError(Exception): + """Raised when project already exists and force is not set.""" + + +@dataclass +class ProjectInitResult: + """Result of project initialization.""" + + success: bool + project_path: Path + action: Literal["created", "overwritten", "skipped"] + message: str + files_created: list[str] = field(default_factory=list) + + +def resolve_project_path( + project_name: str, + base_dir: Path | None = None, + team: str | None = None, +) -> Path: + """Resolve the final project path from components. + + Args: + project_name: Name of the project (becomes directory name) + base_dir: Base directory (uses env var or cwd if None) + team: Optional team subdirectory + + Returns: + Resolved absolute path to project directory + + Example: + >>> resolve_project_path("my_project") + PosixPath('/current/dir/my_project') + >>> resolve_project_path("my_project", Path("assets/dbt_projects"), "analytics") + PosixPath('/current/dir/assets/dbt_projects/analytics/my_project') + """ + config = ProjectConfig() + effective_base = base_dir or config.base_dir or Path.cwd() + + # Make path absolute if relative + if not effective_base.is_absolute(): + effective_base = Path.cwd() / effective_base + + if team: + return effective_base / team / project_name + return effective_base / project_name + + +def fetch_package_version(package: str) -> str | None: + """Fetch the latest version of a package from dbt Hub. + + Args: + package: Package name (e.g., "dbt-labs/dbt_utils") + + Returns: + Version string (e.g., ">=1.3.0") or None if fetch fails + """ + logger = get_logger() + + try: + namespace, name = package.split("/") + url = f"https://hub.getdbt.com/api/v1/{namespace}/{name}/latest.json" + + logger.debug("Fetching package version from: %s", url) + + # Simple HTTP GET with timeout + with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 + import json + + data = json.loads(response.read().decode()) + version = data.get("version") + if version: + logger.debug("Found version %s for %s", version, package) + return f">={version}" + except Exception as e: + logger.debug("Failed to fetch version for %s: %s", package, e) + + return None + + +def get_package_version(package: str) -> str: + """Get the version for a package, with fallback to defaults. + + Args: + package: Package name (e.g., "dbt-labs/dbt_utils") + + Returns: + Version string (e.g., ">=1.0.0") + """ + # Try to fetch from API + version = fetch_package_version(package) + if version: + return version + + # Fall back to defaults + return DEFAULT_PACKAGE_VERSIONS.get(package, ">=0.1.0") + + +def fetch_package_versions_parallel(pkg_names: list[str], max_workers: int = 5) -> dict[str, str]: + """Fetch multiple package versions in parallel. + + Args: + pkg_names: List of package names (e.g., ["dbt-labs/dbt_utils", "elementary-data/elementary"]) + max_workers: Maximum number of concurrent threads + + Returns: + Dictionary mapping package names to version strings + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + + logger = get_logger() + results: dict[str, str] = {} + + logger.debug("Fetching %d package versions in parallel", len(pkg_names)) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(get_package_version, pkg): pkg for pkg in pkg_names} + for future in as_completed(futures): + pkg_name = futures[future] + try: + results[pkg_name] = future.result() + except Exception as e: + logger.debug("Failed to fetch version for %s: %s", pkg_name, e) + results[pkg_name] = DEFAULT_PACKAGE_VERSIONS.get(pkg_name, ">=0.1.0") + + return results + + +def create_project_structure( + project_path: Path, + project_name: str, + profile_name: str, + *, + packages: list[HubPackage] | None = None, + materialization: str | None = None, + persist_docs: bool = False, + with_example: bool = False, +) -> list[str]: + """Create the dbt project directory structure and files. + + Args: + project_path: Path to create project in + project_name: Name of the project + profile_name: Name of the profile to use + packages: List of packages to include (uses template default if None) + materialization: Default materialization (view, table, ephemeral) + persist_docs: Whether to enable persist_docs for Databricks + with_example: Whether to create example model + + Returns: + List of created file paths (relative to project_path) + """ + logger = get_logger() + created_files: list[str] = [] + + # Create main directories + directories = ["models", "seeds", "tests", "macros", "snapshots", "analyses"] + for dir_name in directories: + dir_path = project_path / dir_name + dir_path.mkdir(parents=True, exist_ok=True) + # Add .gitkeep to empty directories + gitkeep = dir_path / ".gitkeep" + gitkeep.touch() + created_files.append(f"{dir_name}/.gitkeep") + logger.debug("Created directory: %s", dir_path) + + # Build dbt_project.yml content + project_config: dict = { + "name": project_name, + "version": "1.0.0", + "profile": profile_name, + "config-version": 2, + "model-paths": ["models"], + "analysis-paths": ["analyses"], + "test-paths": ["tests"], + "seed-paths": ["seeds"], + "macro-paths": ["macros"], + "snapshot-paths": ["snapshots"], + "clean-targets": ["target", "dbt_packages"], + } + + # Add models config if needed (materialization or persist_docs) + if materialization or persist_docs: + models_config: dict = {} + if materialization and materialization != "view": + models_config["+materialized"] = materialization + if persist_docs: + models_config["+persist_docs"] = {"relation": True, "columns": True} + if models_config: + project_config["models"] = {project_name: models_config} + + # Create dbt_project.yml + project = DbtProject(**project_config) + project_yml_path = project_path / "dbt_project.yml" + project_yml_path.write_text(project.to_yaml()) + created_files.append("dbt_project.yml") + logger.debug("Created: %s", project_yml_path) + + # Create packages.yml only if packages were specified + if packages is not None: + dbt_packages = DbtPackages(packages=list(packages)) + packages_content = dbt_packages.to_yaml() + packages_yml_path = project_path / "packages.yml" + packages_yml_path.write_text(packages_content) + created_files.append("packages.yml") + logger.debug("Created: %s", packages_yml_path) + + # Create .gitignore + gitignore_content = get_template("dbt_gitignore") + gitignore_path = project_path / ".gitignore" + gitignore_path.write_text(gitignore_content) + created_files.append(".gitignore") + logger.debug("Created: %s", gitignore_path) + + # Create example model if requested + if with_example: + example_dir = project_path / "models" / "example" + example_dir.mkdir(parents=True, exist_ok=True) + + # Create example model SQL + model_content = get_template("example_model.sql") + model_path = example_dir / "my_first_model.sql" + model_path.write_text(model_content) + created_files.append("models/example/my_first_model.sql") + logger.debug("Created: %s", model_path) + + # Create example schema YAML + schema_content = get_template("example_schema.yml") + schema_path = example_dir / "schema.yml" + schema_path.write_text(schema_content) + created_files.append("models/example/schema.yml") + logger.debug("Created: %s", schema_path) + + return created_files + + +def init_project( + project_name: str, + profile_name: str, + base_dir: Path | None = None, + team: str | None = None, + *, + packages: list[HubPackage] | None = None, + materialization: str | None = None, + persist_docs: bool = False, + with_example: bool = False, + force: bool = False, +) -> ProjectInitResult: + """Initialize a new dbt project. + + Args: + project_name: Name of the project + profile_name: Name of the profile to use + base_dir: Base directory for project (uses env var or cwd if None) + team: Optional team subdirectory + packages: List of packages to include + materialization: Default materialization (view, table, ephemeral) + persist_docs: Whether to enable persist_docs for Databricks + with_example: Whether to create example model + force: Overwrite existing project if True + + Returns: + ProjectInitResult with success status and details + + Raises: + ProjectExistsError: If project exists and force is False + ProjectNameError: If project name is invalid + """ + logger = get_logger() + + # Validate project name + validate_project_name(project_name) + + # Resolve project path + project_path = resolve_project_path(project_name, base_dir, team) + logger.debug("Project path: %s", project_path) + + # Check if project exists + dbt_project_yml = project_path / "dbt_project.yml" + if dbt_project_yml.exists() and not force: + msg = f"Project already exists at {project_path}. Use --force to overwrite." + raise ProjectExistsError(msg) + + # Determine action + action: Literal["created", "overwritten", "skipped"] = "overwritten" if dbt_project_yml.exists() else "created" + + # Create project directory if needed + project_path.mkdir(parents=True, exist_ok=True) + + # Create project structure + files_created = create_project_structure( + project_path=project_path, + project_name=project_name, + profile_name=profile_name, + packages=packages, + materialization=materialization, + persist_docs=persist_docs, + with_example=with_example, + ) + + logger.info("Project %s at %s", action, project_path) + + return ProjectInitResult( + success=True, + project_path=project_path, + action=action, + message=f"Project {action} at {project_path}", + files_created=files_created, + ) diff --git a/src/brix/templates/dbt_gitignore b/src/brix/templates/dbt_gitignore new file mode 100644 index 0000000..713eac8 --- /dev/null +++ b/src/brix/templates/dbt_gitignore @@ -0,0 +1,15 @@ +# dbt +target/ +dbt_packages/ +logs/ +dbt.log + +# Python +__pycache__/ +*.pyc +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/src/brix/templates/dbt_project.yml b/src/brix/templates/dbt_project.yml new file mode 100644 index 0000000..5b36ae5 --- /dev/null +++ b/src/brix/templates/dbt_project.yml @@ -0,0 +1,16 @@ +name: '{{ project_name }}' +version: '1.0.0' +profile: '{{ profile_name }}' + +config-version: 2 + +model-paths: ["models"] +analysis-paths: ["analyses"] +test-paths: ["tests"] +seed-paths: ["seeds"] +macro-paths: ["macros"] +snapshot-paths: ["snapshots"] + +clean-targets: + - "target" + - "dbt_packages" diff --git a/src/brix/templates/example_model.sql b/src/brix/templates/example_model.sql new file mode 100644 index 0000000..9980521 --- /dev/null +++ b/src/brix/templates/example_model.sql @@ -0,0 +1,8 @@ +-- This is an example dbt model +-- Models are SELECT statements that dbt materializes as views or tables +-- Delete this file once you've created your own models + +SELECT + 1 AS id, + 'hello' AS message, + CURRENT_TIMESTAMP AS created_at diff --git a/src/brix/templates/example_schema.yml b/src/brix/templates/example_schema.yml new file mode 100644 index 0000000..c2c1559 --- /dev/null +++ b/src/brix/templates/example_schema.yml @@ -0,0 +1,12 @@ +version: 2 + +models: + - name: my_first_model + description: "An example model to help you get started" + columns: + - name: id + description: "Example ID column" + - name: message + description: "Example message" + - name: created_at + description: "Timestamp when this was queried" diff --git a/src/brix/templates/packages.yml b/src/brix/templates/packages.yml new file mode 100644 index 0000000..c430a50 --- /dev/null +++ b/src/brix/templates/packages.yml @@ -0,0 +1,23 @@ +packages: + - package: dbt-labs/dbt_utils + version: ">=1.0.0" + +# ============================================ +# Package Examples (uncomment to use) +# ============================================ + +# Hub packages (from hub.getdbt.com): +# - package: elementary-data/elementary +# version: ">=0.13.0" +# - package: dbt-labs/codegen +# version: ">=0.12.0" +# - package: calogica/dbt_expectations +# version: ">=0.10.0" + +# Git packages (any git repository): +# - git: "https://github.com/org/repo.git" +# revision: main # branch, tag, or commit hash +# subdirectory: "path/to/dbt_project" # optional, if not at root + +# Local packages (filesystem path): +# - local: ../shared_macros diff --git a/src/brix/version_check.py b/src/brix/version_check.py index 880b533..9da3306 100644 --- a/src/brix/version_check.py +++ b/src/brix/version_check.py @@ -5,6 +5,7 @@ from pathlib import Path import httpx +from packaging.version import Version from pydantic import BaseModel, ValidationError from brix import __version__ @@ -86,7 +87,7 @@ def check_for_updates() -> str | None: thread.start() # Return cached result immediately (or None if no cache yet) - if cache and cache.latest_version != __version__: + if cache and Version(cache.latest_version) > Version(__version__): logger.debug("Update available: %s -> %s", __version__, cache.latest_version) return cache.latest_version return None diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..d4b9218 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +# End-to-end tests for full brix CLI workflows diff --git a/tests/e2e/test_brix_workflow.py b/tests/e2e/test_brix_workflow.py new file mode 100644 index 0000000..658fe6d --- /dev/null +++ b/tests/e2e/test_brix_workflow.py @@ -0,0 +1,243 @@ +"""End-to-end tests for full brix CLI workflow. + +These tests exercise the complete brix workflow from scratch: +1. Create a profiles.yml using brix dbt profile commands +2. Create a dbt project using brix dbt project init +3. Run dbt via brix dbt run +4. Validate the results +""" + +import pytest +from typer.testing import CliRunner + +from brix.main import app + +runner = CliRunner() + + +@pytest.mark.e2e +class TestBrixDuckDbWorkflow: + """E2E test: create profile + project from scratch, run dbt with DuckDB in-memory.""" + + def test_full_duckdb_workflow(self, tmp_path): + """Test complete brix workflow with DuckDB in-memory.""" + profiles_dir = tmp_path / "profiles" + profiles_dir.mkdir() + profiles_path = profiles_dir / "profiles.yml" + + # 1. Initialize profiles.yml from template + result = runner.invoke(app, ["dbt", "profile", "init", "-p", str(profiles_path)]) + assert result.exit_code == 0, f"profile init failed: {result.output}" + assert profiles_path.exists(), "profiles.yml was not created" + + # 2. Add DuckDB in-memory profile + result = runner.invoke( + app, + [ + "dbt", + "profile", + "edit", + "-p", + str(profiles_path), + "--action", + "add-profile", + "--profile", + "e2e_test", + "--target", + "dev", + "--path", + ":memory:", + ], + ) + assert result.exit_code == 0, f"profile edit failed: {result.output}" + + # Verify profile was added + profiles_content = profiles_path.read_text() + assert "e2e_test:" in profiles_content + assert "type: duckdb" in profiles_content + + # 3. Create dbt project (no packages to avoid needing dbt deps) + result = runner.invoke( + app, + [ + "dbt", + "project", + "init", + "-n", + "e2e_project", + "-p", + "e2e_test", + "-b", + str(tmp_path), + "--with-example", + "--no-packages", + ], + ) + assert result.exit_code == 0, f"project init failed: {result.output}" + + project_dir = tmp_path / "e2e_project" + assert project_dir.exists(), "Project directory was not created" + assert (project_dir / "dbt_project.yml").exists(), "dbt_project.yml was not created" + assert (project_dir / "models").exists(), "models directory was not created" + + # 4. Run dbt + result = runner.invoke( + app, + ["dbt", "run", "--project-dir", str(project_dir), "--profiles-dir", str(profiles_dir)], + ) + assert result.exit_code == 0, f"dbt run failed: {result.output}" + + # 5. Validate target directory was created (dbt ran successfully) + assert (project_dir / "target").exists(), "target directory was not created by dbt run" + + +@pytest.mark.e2e +class TestProjectWithPackages: + """E2E tests for project creation with packages.""" + + def test_project_init_with_packages(self, tmp_path): + """Test creating a project with packages (tests parallel fetching).""" + # Create project with packages + result = runner.invoke( + app, + [ + "dbt", + "project", + "init", + "-n", + "pkg_test_project", + "-p", + "default", + "-b", + str(tmp_path), + "--packages", + "dbt-labs/dbt_utils", + "--packages", + "dbt-labs/codegen", + ], + ) + assert result.exit_code == 0, f"project init failed: {result.output}" + assert "Fetching package versions" in result.output + + project_dir = tmp_path / "pkg_test_project" + assert project_dir.exists(), "Project directory was not created" + + # Verify packages.yml was created with packages + packages_yml = project_dir / "packages.yml" + assert packages_yml.exists(), "packages.yml was not created" + packages_content = packages_yml.read_text() + assert "dbt-labs/dbt_utils" in packages_content + assert "dbt-labs/codegen" in packages_content + + def test_project_init_invalid_package_name(self, tmp_path): + """Test that invalid package names are rejected.""" + result = runner.invoke( + app, + [ + "dbt", + "project", + "init", + "-n", + "invalid_pkg_project", + "-p", + "default", + "-b", + str(tmp_path), + "--packages", + "invalid-package-name", # Missing namespace/ + ], + ) + assert result.exit_code == 1, "Should fail with invalid package name" + assert "Invalid hub package name" in result.output + + +@pytest.mark.e2e +class TestProjectEdit: + """E2E tests for project editing.""" + + def test_project_edit_add_hub_package(self, tmp_path): + """Test adding a hub package to an existing project.""" + # Create initial project without packages + result = runner.invoke( + app, + [ + "dbt", + "project", + "init", + "-n", + "edit_test_project", + "-p", + "default", + "-b", + str(tmp_path), + "--no-packages", + ], + ) + assert result.exit_code == 0, f"project init failed: {result.output}" + + project_dir = tmp_path / "edit_test_project" + project_yml = project_dir / "dbt_project.yml" + + # Add a package using edit command + result = runner.invoke( + app, + [ + "dbt", + "project", + "edit", + "-p", + str(project_yml), + "--action", + "add-hub-package", + "--package", + "dbt-labs/dbt_utils", + ], + ) + assert result.exit_code == 0, f"project edit failed: {result.output}" + assert "Added hub package" in result.output + + # Verify packages.yml was created/updated + packages_yml = project_dir / "packages.yml" + assert packages_yml.exists(), "packages.yml was not created" + packages_content = packages_yml.read_text() + assert "dbt-labs/dbt_utils" in packages_content + + def test_project_edit_invalid_package_name(self, tmp_path): + """Test that edit rejects invalid package names.""" + # Create initial project + result = runner.invoke( + app, + [ + "dbt", + "project", + "init", + "-n", + "edit_invalid_project", + "-p", + "default", + "-b", + str(tmp_path), + "--no-packages", + ], + ) + assert result.exit_code == 0, f"project init failed: {result.output}" + + project_yml = tmp_path / "edit_invalid_project" / "dbt_project.yml" + + # Try to add invalid package + result = runner.invoke( + app, + [ + "dbt", + "project", + "edit", + "-p", + str(project_yml), + "--action", + "add-hub-package", + "--package", + "not-valid-format", + ], + ) + assert result.exit_code == 1, "Should fail with invalid package name" + assert "Invalid hub package name" in result.output diff --git a/tests/integration/test_dbt_integration.py b/tests/integration/test_dbt_integration.py index 1fa5d92..4e0bae7 100644 --- a/tests/integration/test_dbt_integration.py +++ b/tests/integration/test_dbt_integration.py @@ -50,8 +50,8 @@ def test_dbt_run(self, dbt_project): @pytest.mark.integration -class TestDbtCliIntegration: - """Integration tests for dbt passthrough via the CLI.""" +class TestBrixDbtIntegration: + """Integration tests for dbt passthrough via the brix CLI.""" def test_brix_dbt_version(self): """Test brix dbt --version runs successfully.""" diff --git a/tests/unit/test_dbt_passthrough.py b/tests/unit/test_dbt_passthrough.py index 91d60d4..026fc79 100644 --- a/tests/unit/test_dbt_passthrough.py +++ b/tests/unit/test_dbt_passthrough.py @@ -1,12 +1,14 @@ """Tests for dbt passthrough command.""" +from pathlib import Path from unittest.mock import MagicMock, patch from typer.testing import CliRunner +import brix.commands.dbt as dbt_command_module import brix.modules.dbt.passthrough as passthrough_module from brix.main import app -from brix.modules.dbt import run_dbt +from brix.modules.dbt import CachedPathNotFoundError, load_project_cache, run_dbt, save_project_cache runner = CliRunner() @@ -16,7 +18,7 @@ def test_forwards_arguments_to_subprocess(self): with patch.object(passthrough_module, "subprocess") as mock_subprocess: mock_subprocess.run.return_value = MagicMock(returncode=0) exit_code = run_dbt(["run", "--select", "my_model"]) - mock_subprocess.run.assert_called_once_with(["dbt", "run", "--select", "my_model"]) + mock_subprocess.run.assert_called_once_with(["dbt", "run", "--select", "my_model"], cwd=None) assert exit_code == 0 def test_returns_exit_code_from_dbt(self): @@ -29,9 +31,97 @@ def test_empty_args(self): with patch.object(passthrough_module, "subprocess") as mock_subprocess: mock_subprocess.run.return_value = MagicMock(returncode=0) exit_code = run_dbt([]) - mock_subprocess.run.assert_called_once_with(["dbt"]) + mock_subprocess.run.assert_called_once_with(["dbt"], cwd=None) assert exit_code == 0 + def test_with_project_path(self, tmp_path: Path): + """Test run_dbt uses project_path as cwd.""" + with patch.object(passthrough_module, "subprocess") as mock_subprocess: + mock_subprocess.run.return_value = MagicMock(returncode=0) + exit_code = run_dbt(["run"], project_path=tmp_path) + mock_subprocess.run.assert_called_once_with(["dbt", "run"], cwd=tmp_path.resolve()) + assert exit_code == 0 + + def test_with_nonexistent_project_path(self): + """Test run_dbt returns error for nonexistent path.""" + nonexistent = Path("/nonexistent/path/that/does/not/exist") + exit_code = run_dbt(["run"], project_path=nonexistent) + assert exit_code == 1 + + def test_with_file_as_project_path(self, tmp_path: Path): + """Test run_dbt returns error when project_path is a file.""" + file_path = tmp_path / "not_a_dir.txt" + file_path.touch() + exit_code = run_dbt(["run"], project_path=file_path) + assert exit_code == 1 + + +class TestProjectPathCache: + def test_save_and_load_cache(self, tmp_path: Path, monkeypatch: MagicMock): + """Test saving and loading project path cache.""" + cache_dir = tmp_path / ".cache" / "brix" + monkeypatch.setattr(passthrough_module, "CACHE_DIR", cache_dir) + monkeypatch.setattr(passthrough_module, "PROJECT_CACHE_FILE", cache_dir / "dbt_project_path.json") + + project_dir = tmp_path / "my_project" + project_dir.mkdir() + + save_project_cache(project_dir) + loaded = load_project_cache() + + assert loaded == project_dir.resolve() + + def test_load_cache_nonexistent_path_raises(self, tmp_path: Path, monkeypatch: MagicMock): + """Test loading cache raises CachedPathNotFoundError when cached path doesn't exist.""" + cache_dir = tmp_path / ".cache" / "brix" + cache_dir.mkdir(parents=True) + monkeypatch.setattr(passthrough_module, "CACHE_DIR", cache_dir) + monkeypatch.setattr(passthrough_module, "PROJECT_CACHE_FILE", cache_dir / "dbt_project_path.json") + + project_dir = tmp_path / "my_project" + project_dir.mkdir() + save_project_cache(project_dir) + + # Delete the project directory + project_dir.rmdir() + + import pytest + + with pytest.raises(CachedPathNotFoundError, match="no longer exists"): + load_project_cache() + + def test_load_cache_no_cache_file(self, tmp_path: Path, monkeypatch: MagicMock): + """Test loading cache returns None when no cache file exists.""" + cache_dir = tmp_path / ".cache" / "brix" + monkeypatch.setattr(passthrough_module, "CACHE_DIR", cache_dir) + monkeypatch.setattr(passthrough_module, "PROJECT_CACHE_FILE", cache_dir / "dbt_project_path.json") + + result = load_project_cache() + assert result is None + + def test_relative_path_converted_to_absolute(self, tmp_path: Path, monkeypatch: MagicMock): + """Test relative paths are converted to absolute before caching.""" + import os + + cache_dir = tmp_path / ".cache" / "brix" + monkeypatch.setattr(passthrough_module, "CACHE_DIR", cache_dir) + monkeypatch.setattr(passthrough_module, "PROJECT_CACHE_FILE", cache_dir / "dbt_project_path.json") + + project_dir = tmp_path / "my_project" + project_dir.mkdir() + + # Change to tmp_path and use relative path + original_cwd = Path.cwd() + try: + os.chdir(tmp_path) + save_project_cache(Path("my_project")) + loaded = load_project_cache() + assert loaded is not None + assert loaded.is_absolute() + assert loaded == project_dir.resolve() + finally: + os.chdir(original_cwd) + class TestDbtCommand: def test_dbt_command_exists(self): @@ -39,23 +129,56 @@ def test_dbt_command_exists(self): assert result.exit_code == 0 assert "dbt" in result.output.lower() - def test_dbt_passthrough_args(self): - with patch.object(passthrough_module, "subprocess") as mock_subprocess: - mock_subprocess.run.return_value = MagicMock(returncode=0) - result = runner.invoke(app, ["dbt", "run", "--select", "my_model"]) - mock_subprocess.run.assert_called_once_with(["dbt", "run", "--select", "my_model"]) + def test_dbt_passthrough_args(self, tmp_path: Path, monkeypatch: MagicMock): + """Test passthrough with --project option.""" + cache_dir = tmp_path / ".cache" / "brix" + monkeypatch.setattr(passthrough_module, "CACHE_DIR", cache_dir) + monkeypatch.setattr(passthrough_module, "PROJECT_CACHE_FILE", cache_dir / "dbt_project_path.json") + + project_dir = tmp_path / "my_project" + project_dir.mkdir() + + with patch.object(dbt_command_module, "run_dbt", return_value=0) as mock_run_dbt: + result = runner.invoke(app, ["dbt", "--project", str(project_dir), "run", "--select", "my_model"]) + mock_run_dbt.assert_called_once_with(["run", "--select", "my_model"], project_path=project_dir.resolve()) assert result.exit_code == 0 - def test_dbt_preserves_exit_code(self): - with patch.object(passthrough_module, "subprocess") as mock_subprocess: - mock_subprocess.run.return_value = MagicMock(returncode=2) - result = runner.invoke(app, ["dbt", "run"]) + def test_dbt_preserves_exit_code(self, tmp_path: Path, monkeypatch: MagicMock): + cache_dir = tmp_path / ".cache" / "brix" + monkeypatch.setattr(passthrough_module, "CACHE_DIR", cache_dir) + monkeypatch.setattr(passthrough_module, "PROJECT_CACHE_FILE", cache_dir / "dbt_project_path.json") + + project_dir = tmp_path / "my_project" + project_dir.mkdir() + + with patch.object(dbt_command_module, "run_dbt", return_value=2): + result = runner.invoke(app, ["dbt", "--project", str(project_dir), "run"]) assert result.exit_code == 2 def test_custom_command_not_passed_through(self): - """Custom commands like 'setup' should not be passed to dbt.""" - with patch.object(passthrough_module, "subprocess") as mock_subprocess: - result = runner.invoke(app, ["dbt", "setup"]) + """Custom commands like 'profile' should not be passed to dbt.""" + with patch.object(dbt_command_module, "run_dbt", return_value=0) as mock_run_dbt: + result = runner.invoke(app, ["dbt", "profile", "-h"]) + assert result.exit_code == 0 + assert "profile" in result.output.lower() # Shows profile help + mock_run_dbt.assert_not_called() + + def test_cached_project_path_used_on_subsequent_calls(self, tmp_path: Path, monkeypatch: MagicMock): + """Test that cached project path is used when --project not provided.""" + cache_dir = tmp_path / ".cache" / "brix" + monkeypatch.setattr(passthrough_module, "CACHE_DIR", cache_dir) + monkeypatch.setattr(passthrough_module, "PROJECT_CACHE_FILE", cache_dir / "dbt_project_path.json") + + project_dir = tmp_path / "my_project" + project_dir.mkdir() + + # First call with --project to cache it + with patch.object(dbt_command_module, "run_dbt", return_value=0): + result = runner.invoke(app, ["dbt", "--project", str(project_dir), "run"]) + assert result.exit_code == 0 + + # Second call without --project should use cached path + with patch.object(dbt_command_module, "run_dbt", return_value=0) as mock_run_dbt: + result = runner.invoke(app, ["dbt", "run"]) + mock_run_dbt.assert_called_once_with(["run"], project_path=project_dir.resolve()) assert result.exit_code == 0 - assert "not yet implemented" in result.output - mock_subprocess.run.assert_not_called() diff --git a/tests/unit/test_dbt_profile.py b/tests/unit/test_dbt_profile.py index ef3d9f9..a9add46 100644 --- a/tests/unit/test_dbt_profile.py +++ b/tests/unit/test_dbt_profile.py @@ -101,7 +101,8 @@ def test_duckdb_default_values(self): output = DuckDbOutput(type="duckdb") assert output.path == ":memory:" assert output.schema_ == "main" - assert output.database == "main" + # database is automatically set to 'memory' when path is ':memory:' + assert output.database == "memory" assert output.threads == 1 assert output.extensions == [] assert output.settings == {} diff --git a/tests/unit/test_dbt_project.py b/tests/unit/test_dbt_project.py new file mode 100644 index 0000000..cb17d21 --- /dev/null +++ b/tests/unit/test_dbt_project.py @@ -0,0 +1,427 @@ +"""Tests for dbt project commands and models.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from brix.main import app +from brix.modules.dbt.project.models import ( + DbtPackages, + DbtProject, + GitPackage, + HubPackage, + LocalPackage, + ProjectNameError, + validate_project_name, +) +from brix.modules.dbt.project.service import ( + ProjectExistsError, + init_project, + resolve_project_path, +) + +runner = CliRunner() + + +class TestProjectNameValidation: + """Tests for project name validation.""" + + def test_valid_project_names(self): + """Test that valid project names pass validation.""" + valid_names = [ + "my_project", + "MyProject", + "_private", + "project123", + "a", + "_", + "my_dbt_project_v2", + ] + for name in valid_names: + assert validate_project_name(name) == name + + def test_invalid_project_names(self): + """Test that invalid project names raise errors.""" + invalid_names = [ + "my-project", # hyphens not allowed + "123project", # can't start with number + "my project", # spaces not allowed + "my.project", # dots not allowed + "", # empty not allowed + "project@name", # special chars not allowed + ] + for name in invalid_names: + with pytest.raises(ProjectNameError): + validate_project_name(name) + + +class TestDbtProject: + """Tests for DbtProject pydantic model.""" + + def test_parse_simple_project(self): + yaml_content = """ +name: my_project +version: '1.0.0' +profile: default +config-version: 2 + +model-paths: ["models"] +seed-paths: ["seeds"] +test-paths: ["tests"] +""" + project = DbtProject.from_yaml(yaml_content) + assert project.name == "my_project" + assert project.version == "1.0.0" + assert project.profile == "default" + assert project.config_version == 2 + assert project.model_paths == ["models"] + + def test_parse_invalid_yaml_raises(self): + with pytest.raises(ValueError, match="Invalid YAML"): + DbtProject.from_yaml("{ invalid yaml") + + def test_parse_non_mapping_raises(self): + with pytest.raises(ValueError, match="must be a YAML mapping"): + DbtProject.from_yaml("- list item") + + def test_to_yaml_roundtrip(self): + project = DbtProject( + name="test_project", + profile="my_profile", + version="2.0.0", + ) + yaml_output = project.to_yaml() + project2 = DbtProject.from_yaml(yaml_output) + assert project2.name == "test_project" + assert project2.profile == "my_profile" + assert project2.version == "2.0.0" + + def test_default_values(self): + """Test default values for optional fields.""" + project = DbtProject(name="test", profile="default") + assert project.version == "1.0.0" + assert project.config_version == 2 + assert project.model_paths == ["models"] + assert project.seed_paths == ["seeds"] + assert project.test_paths == ["tests"] + assert project.clean_targets == ["target", "dbt_packages"] + + def test_project_with_models_config(self): + """Test project with models configuration.""" + project = DbtProject( + name="test_project", + profile="databricks_dev", + models={ + "test_project": { + "+materialized": "table", + "+persist_docs": {"relation": True, "columns": True}, + } + }, + ) + yaml_output = project.to_yaml() + assert "+materialized" in yaml_output + assert "table" in yaml_output + assert "+persist_docs" in yaml_output + + def test_invalid_project_name_raises(self): + """Test that invalid project name in model raises error.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="Invalid project name"): + DbtProject(name="invalid-name", profile="default") + + +class TestDbtPackages: + """Tests for DbtPackages pydantic model.""" + + def test_parse_hub_packages(self): + yaml_content = """ +packages: + - package: dbt-labs/dbt_utils + version: ">=1.0.0" + - package: elementary-data/elementary + version: ">=0.13.0" +""" + packages = DbtPackages.from_yaml(yaml_content) + assert len(packages.packages) == 2 + assert isinstance(packages.packages[0], HubPackage) + assert packages.packages[0].package == "dbt-labs/dbt_utils" + assert packages.packages[0].version == ">=1.0.0" + + def test_parse_git_package(self): + yaml_content = """ +packages: + - git: "https://github.com/org/repo.git" + revision: main + subdirectory: dbt_project +""" + packages = DbtPackages.from_yaml(yaml_content) + assert len(packages.packages) == 1 + pkg = packages.packages[0] + assert isinstance(pkg, GitPackage) + assert pkg.git == "https://github.com/org/repo.git" + assert pkg.revision == "main" + assert pkg.subdirectory == "dbt_project" + + def test_parse_local_package(self): + yaml_content = """ +packages: + - local: ../shared_macros +""" + packages = DbtPackages.from_yaml(yaml_content) + assert len(packages.packages) == 1 + pkg = packages.packages[0] + assert isinstance(pkg, LocalPackage) + assert pkg.local == "../shared_macros" + + def test_empty_packages(self): + packages = DbtPackages.from_yaml("") + assert packages.packages == [] + + def test_add_hub_package(self): + packages = DbtPackages() + packages.add_hub_package("dbt-labs/dbt_utils", ">=1.0.0") + assert len(packages.packages) == 1 + assert packages.packages[0].package == "dbt-labs/dbt_utils" + + def test_to_yaml_roundtrip(self): + packages = DbtPackages() + packages.add_hub_package("dbt-labs/dbt_utils", ">=1.0.0") + packages.add_git_package("https://github.com/org/repo.git", "main") + + yaml_output = packages.to_yaml() + packages2 = DbtPackages.from_yaml(yaml_output) + + assert len(packages2.packages) == 2 + assert isinstance(packages2.packages[0], HubPackage) + assert isinstance(packages2.packages[1], GitPackage) + + +class TestResolveProjectPath: + """Tests for project path resolution.""" + + def test_simple_project_name(self, tmp_path): + """Test resolution with just project name.""" + with patch.object(Path, "cwd", return_value=tmp_path): + path = resolve_project_path("my_project") + assert path == tmp_path / "my_project" + + def test_with_base_dir(self, tmp_path): + """Test resolution with base directory.""" + base = tmp_path / "projects" + path = resolve_project_path("my_project", base_dir=base) + assert path == base / "my_project" + + def test_with_team(self, tmp_path): + """Test resolution with team subdirectory.""" + base = tmp_path / "projects" + path = resolve_project_path("my_project", base_dir=base, team="analytics") + assert path == base / "analytics" / "my_project" + + def test_relative_base_dir(self, tmp_path): + """Test that relative base dir is made absolute.""" + with patch.object(Path, "cwd", return_value=tmp_path): + path = resolve_project_path("my_project", base_dir=Path("subdir")) + assert path.is_absolute() + assert path == tmp_path / "subdir" / "my_project" + + +class TestInitProject: + """Tests for project initialization.""" + + def test_init_creates_project(self, tmp_path): + """Test that init_project creates all expected files.""" + result = init_project( + project_name="test_project", + profile_name="default", + base_dir=tmp_path, + ) + + assert result.success + assert result.project_path == tmp_path / "test_project" + assert (tmp_path / "test_project" / "dbt_project.yml").exists() + # packages.yml is only created when packages are explicitly specified + assert not (tmp_path / "test_project" / "packages.yml").exists() + assert (tmp_path / "test_project" / ".gitignore").exists() + assert (tmp_path / "test_project" / "models").is_dir() + assert (tmp_path / "test_project" / "seeds").is_dir() + + def test_init_with_packages(self, tmp_path): + """Test init with custom packages.""" + packages = [ + HubPackage(package="dbt-labs/dbt_utils", version=">=1.0.0"), + HubPackage(package="elementary-data/elementary", version=">=0.13.0"), + ] + result = init_project( + project_name="test_project", + profile_name="default", + base_dir=tmp_path, + packages=packages, + ) + + assert result.success + packages_yml = (tmp_path / "test_project" / "packages.yml").read_text() + assert "dbt-labs/dbt_utils" in packages_yml + assert "elementary-data/elementary" in packages_yml + + def test_init_with_example(self, tmp_path): + """Test init with example model.""" + result = init_project( + project_name="test_project", + profile_name="default", + base_dir=tmp_path, + with_example=True, + ) + + assert result.success + assert (tmp_path / "test_project" / "models" / "example" / "my_first_model.sql").exists() + assert (tmp_path / "test_project" / "models" / "example" / "schema.yml").exists() + + def test_init_with_materialization(self, tmp_path): + """Test init with custom materialization.""" + result = init_project( + project_name="test_project", + profile_name="databricks_dev", + base_dir=tmp_path, + materialization="table", + ) + + assert result.success + project_yml = (tmp_path / "test_project" / "dbt_project.yml").read_text() + assert "+materialized: table" in project_yml + + def test_init_with_persist_docs(self, tmp_path): + """Test init with persist_docs enabled.""" + result = init_project( + project_name="test_project", + profile_name="databricks_dev", + base_dir=tmp_path, + persist_docs=True, + ) + + assert result.success + project_yml = (tmp_path / "test_project" / "dbt_project.yml").read_text() + assert "+persist_docs" in project_yml + assert "relation: true" in project_yml + + def test_init_existing_project_raises(self, tmp_path): + """Test that init raises when project exists.""" + # Create existing project + project_dir = tmp_path / "existing_project" + project_dir.mkdir() + (project_dir / "dbt_project.yml").write_text("name: existing") + + with pytest.raises(ProjectExistsError): + init_project( + project_name="existing_project", + profile_name="default", + base_dir=tmp_path, + ) + + def test_init_force_overwrites(self, tmp_path): + """Test that init with force overwrites existing project.""" + # Create existing project + project_dir = tmp_path / "existing_project" + project_dir.mkdir() + (project_dir / "dbt_project.yml").write_text("name: old_name") + + result = init_project( + project_name="existing_project", + profile_name="default", + base_dir=tmp_path, + force=True, + ) + + assert result.success + assert result.action == "overwritten" + project_yml = (project_dir / "dbt_project.yml").read_text() + assert "existing_project" in project_yml + + def test_init_invalid_name_raises(self, tmp_path): + """Test that init raises for invalid project name.""" + with pytest.raises(ProjectNameError): + init_project( + project_name="invalid-name", + profile_name="default", + base_dir=tmp_path, + ) + + +class TestProjectCli: + """Tests for project CLI commands.""" + + def test_project_init_help(self): + """Test that project init --help works.""" + result = runner.invoke(app, ["dbt", "project", "init", "--help"]) + assert result.exit_code == 0 + assert "Initialize a new dbt project" in result.stdout + + def test_project_init_requires_profile_in_cli_mode(self): + """Test that --profile is required when --project-name is given.""" + result = runner.invoke(app, ["dbt", "project", "init", "-n", "test_project"]) + assert result.exit_code == 1 + assert "--profile is required" in result.stdout + + def test_project_init_cli_mode(self, tmp_path): + """Test CLI mode project initialization.""" + result = runner.invoke( + app, + [ + "dbt", + "project", + "init", + "-n", + "test_cli_project", + "-b", + str(tmp_path), + "-p", + "default", + "--no-run-deps", + ], + ) + assert result.exit_code == 0 + assert "Project created" in result.stdout or "Project initialization complete" in result.stdout + assert (tmp_path / "test_cli_project" / "dbt_project.yml").exists() + + def test_project_init_with_team(self, tmp_path): + """Test CLI mode with team option.""" + result = runner.invoke( + app, + [ + "dbt", + "project", + "init", + "-n", + "my_project", + "-b", + str(tmp_path), + "-t", + "data_team", + "-p", + "default", + "--no-run-deps", + ], + ) + assert result.exit_code == 0 + assert (tmp_path / "data_team" / "my_project" / "dbt_project.yml").exists() + + def test_project_init_invalid_name(self, tmp_path): + """Test that invalid project name fails.""" + result = runner.invoke( + app, + [ + "dbt", + "project", + "init", + "-n", + "invalid-name", + "-b", + str(tmp_path), + "-p", + "default", + ], + ) + assert result.exit_code == 1 + assert "Invalid project name" in result.stdout or "must start with" in result.stdout diff --git a/tests/unit/test_dbt_project_editor.py b/tests/unit/test_dbt_project_editor.py new file mode 100644 index 0000000..322dddd --- /dev/null +++ b/tests/unit/test_dbt_project_editor.py @@ -0,0 +1,404 @@ +"""Unit tests for dbt project editor module.""" + +from pathlib import Path + +import pytest + +from brix.modules.dbt.project.editor import ( + EDITABLE_FIELDS, + PATH_FIELDS, + InvalidFieldError, + PackageAlreadyExistsError, + PackageNotFoundError, + ProjectNotFoundError, + add_git_package, + add_hub_package, + add_local_package, + find_package_index, + get_package_display_info, + get_package_identifiers, + has_package, + load_packages, + load_project, + remove_package, + save_packages, + save_project, + update_package_version, + update_path_field, + update_project_field, +) +from brix.modules.dbt.project.models import DbtPackages, DbtProject, GitPackage, HubPackage, LocalPackage + + +class TestLoadSaveProject: + """Tests for project loading and saving.""" + + def test_load_project(self, tmp_path: Path) -> None: + """Test loading a valid project.""" + project_file = tmp_path / "dbt_project.yml" + project_file.write_text( + """ +name: test_project +profile: default +version: "1.0.0" +""" + ) + + project = load_project(project_file) + assert project.name == "test_project" + assert project.profile == "default" + assert project.version == "1.0.0" + + def test_load_project_not_found(self, tmp_path: Path) -> None: + """Test loading a non-existent project.""" + project_file = tmp_path / "dbt_project.yml" + + with pytest.raises(ProjectNotFoundError): + load_project(project_file) + + def test_save_project(self, tmp_path: Path) -> None: + """Test saving a project.""" + project_file = tmp_path / "dbt_project.yml" + project = DbtProject(name="new_project", profile="test") + + save_project(project, project_file) + + assert project_file.exists() + content = project_file.read_text() + assert "new_project" in content + assert "test" in content + + def test_save_creates_parent_dirs(self, tmp_path: Path) -> None: + """Test saving creates parent directories.""" + project_file = tmp_path / "nested" / "dir" / "dbt_project.yml" + project = DbtProject(name="nested_project", profile="test") + + save_project(project, project_file) + + assert project_file.exists() + + +class TestUpdateProjectField: + """Tests for project field updates.""" + + def test_update_name(self) -> None: + """Test updating project name.""" + project = DbtProject(name="old_name", profile="default") + + updated = update_project_field(project, "name", "new_name") + + assert updated.name == "new_name" + + def test_update_profile(self) -> None: + """Test updating profile.""" + project = DbtProject(name="test", profile="old_profile") + + updated = update_project_field(project, "profile", "new_profile") + + assert updated.profile == "new_profile" + + def test_update_version(self) -> None: + """Test updating version.""" + project = DbtProject(name="test", profile="default", version="1.0.0") + + updated = update_project_field(project, "version", "2.0.0") + + assert updated.version == "2.0.0" + + def test_update_require_dbt_version(self) -> None: + """Test updating require_dbt_version.""" + project = DbtProject(name="test", profile="default") + + updated = update_project_field(project, "require_dbt_version", ">=1.0.0") + + assert updated.require_dbt_version == ">=1.0.0" + + def test_update_invalid_field_raises(self) -> None: + """Test updating an invalid field raises error.""" + project = DbtProject(name="test", profile="default") + + with pytest.raises(InvalidFieldError): + update_project_field(project, "invalid_field", "value") + + def test_update_restricted_field_raises(self) -> None: + """Test updating a restricted field raises error.""" + project = DbtProject(name="test", profile="default") + + # vars is not in EDITABLE_FIELDS + with pytest.raises(InvalidFieldError): + update_project_field(project, "vars", "some_value") + + def test_field_name_with_dashes(self) -> None: + """Test field names with dashes are converted to underscores.""" + project = DbtProject(name="test", profile="default") + + # Should work with dashes + updated = update_project_field(project, "require-dbt-version", ">=1.0.0") + + assert updated.require_dbt_version == ">=1.0.0" + + def test_editable_fields_constant(self) -> None: + """Test that EDITABLE_FIELDS contains expected values.""" + assert "name" in EDITABLE_FIELDS + assert "profile" in EDITABLE_FIELDS + assert "version" in EDITABLE_FIELDS + assert "require_dbt_version" in EDITABLE_FIELDS + + +class TestUpdatePathField: + """Tests for path field updates.""" + + def test_add_path(self) -> None: + """Test adding a path.""" + project = DbtProject(name="test", profile="default", model_paths=["models"]) + + updated = update_path_field(project, "model_paths", "add", "staging") + + assert "staging" in updated.model_paths + assert "models" in updated.model_paths + + def test_add_path_no_duplicate(self) -> None: + """Test adding an existing path doesn't duplicate.""" + project = DbtProject(name="test", profile="default", model_paths=["models"]) + + updated = update_path_field(project, "model_paths", "add", "models") + + assert updated.model_paths.count("models") == 1 + + def test_remove_path(self) -> None: + """Test removing a path.""" + project = DbtProject(name="test", profile="default", model_paths=["models", "staging"]) + + updated = update_path_field(project, "model_paths", "remove", "staging") + + assert "staging" not in updated.model_paths + assert "models" in updated.model_paths + + def test_remove_nonexistent_path_raises(self) -> None: + """Test removing a non-existent path raises error.""" + project = DbtProject(name="test", profile="default", model_paths=["models"]) + + with pytest.raises(ValueError, match="not found"): + update_path_field(project, "model_paths", "remove", "nonexistent") + + def test_set_paths(self) -> None: + """Test setting paths completely.""" + project = DbtProject(name="test", profile="default", model_paths=["models"]) + + updated = update_path_field(project, "model_paths", "set", ["new_models", "staging"]) + + assert updated.model_paths == ["new_models", "staging"] + + def test_invalid_path_field_raises(self) -> None: + """Test invalid path field raises error.""" + project = DbtProject(name="test", profile="default") + + with pytest.raises(InvalidFieldError): + update_path_field(project, "invalid_paths", "add", "value") + + def test_path_field_with_dashes(self) -> None: + """Test path fields with dashes are converted.""" + project = DbtProject(name="test", profile="default", model_paths=["models"]) + + updated = update_path_field(project, "model-paths", "add", "staging") + + assert "staging" in updated.model_paths + + def test_path_fields_constant(self) -> None: + """Test that PATH_FIELDS contains expected values.""" + assert "model_paths" in PATH_FIELDS + assert "seed_paths" in PATH_FIELDS + assert "test_paths" in PATH_FIELDS + assert "macro_paths" in PATH_FIELDS + assert "clean_targets" in PATH_FIELDS + + +class TestLoadSavePackages: + """Tests for package loading and saving.""" + + def test_load_packages(self, tmp_path: Path) -> None: + """Test loading packages.""" + packages_file = tmp_path / "packages.yml" + packages_file.write_text( + """ +packages: + - package: dbt-labs/dbt_utils + version: ">=1.0.0" +""" + ) + + packages = load_packages(tmp_path) + + assert len(packages.packages) == 1 + assert isinstance(packages.packages[0], HubPackage) + + def test_load_packages_not_found_returns_empty(self, tmp_path: Path) -> None: + """Test loading non-existent packages.yml returns empty.""" + packages = load_packages(tmp_path) + + assert packages.packages == [] + + def test_load_packages_from_file_path(self, tmp_path: Path) -> None: + """Test loading packages when given dbt_project.yml path.""" + packages_file = tmp_path / "packages.yml" + packages_file.write_text( + """ +packages: + - package: dbt-labs/dbt_utils + version: ">=1.0.0" +""" + ) + project_file = tmp_path / "dbt_project.yml" + + packages = load_packages(project_file) + + assert len(packages.packages) == 1 + + def test_save_packages(self, tmp_path: Path) -> None: + """Test saving packages.""" + packages = DbtPackages(packages=[HubPackage(package="dbt-labs/dbt_utils", version=">=1.0.0")]) + + save_packages(packages, tmp_path) + + packages_file = tmp_path / "packages.yml" + assert packages_file.exists() + content = packages_file.read_text() + assert "dbt-labs/dbt_utils" in content + + +class TestPackageOperations: + """Tests for package CRUD operations.""" + + @pytest.fixture + def empty_packages(self) -> DbtPackages: + """Create empty packages.""" + return DbtPackages(packages=[]) + + @pytest.fixture + def packages_with_hub(self) -> DbtPackages: + """Create packages with a hub package.""" + return DbtPackages(packages=[HubPackage(package="dbt-labs/dbt_utils", version=">=1.0.0")]) + + def test_add_hub_package(self, empty_packages: DbtPackages) -> None: + """Test adding a hub package.""" + updated = add_hub_package(empty_packages, "dbt-labs/codegen", ">=0.10.0") + + assert len(updated.packages) == 1 + assert isinstance(updated.packages[0], HubPackage) + assert updated.packages[0].package == "dbt-labs/codegen" + + def test_add_hub_package_duplicate_raises(self, packages_with_hub: DbtPackages) -> None: + """Test adding duplicate hub package raises error.""" + with pytest.raises(PackageAlreadyExistsError): + add_hub_package(packages_with_hub, "dbt-labs/dbt_utils", ">=2.0.0") + + def test_add_git_package(self, empty_packages: DbtPackages) -> None: + """Test adding a git package.""" + updated = add_git_package(empty_packages, "https://github.com/org/repo.git", "main", "subdir") + + assert len(updated.packages) == 1 + assert isinstance(updated.packages[0], GitPackage) + assert updated.packages[0].git == "https://github.com/org/repo.git" + assert updated.packages[0].revision == "main" + assert updated.packages[0].subdirectory == "subdir" + + def test_add_git_package_without_subdirectory(self, empty_packages: DbtPackages) -> None: + """Test adding git package without subdirectory.""" + updated = add_git_package(empty_packages, "https://github.com/org/repo.git", "v1.0.0") + + assert isinstance(updated.packages[0], GitPackage) + assert updated.packages[0].subdirectory is None + + def test_add_local_package(self, empty_packages: DbtPackages) -> None: + """Test adding a local package.""" + updated = add_local_package(empty_packages, "../shared_macros") + + assert len(updated.packages) == 1 + assert isinstance(updated.packages[0], LocalPackage) + assert updated.packages[0].local == "../shared_macros" + + def test_remove_package(self, packages_with_hub: DbtPackages) -> None: + """Test removing a package.""" + updated = remove_package(packages_with_hub, "dbt-labs/dbt_utils") + + assert len(updated.packages) == 0 + + def test_remove_package_not_found_raises(self, empty_packages: DbtPackages) -> None: + """Test removing non-existent package raises error.""" + with pytest.raises(PackageNotFoundError): + remove_package(empty_packages, "nonexistent") + + def test_update_package_version(self, packages_with_hub: DbtPackages) -> None: + """Test updating hub package version.""" + updated = update_package_version(packages_with_hub, "dbt-labs/dbt_utils", ">=2.0.0") + + assert isinstance(updated.packages[0], HubPackage) + assert updated.packages[0].version == ">=2.0.0" + + def test_update_package_version_not_found_raises(self, empty_packages: DbtPackages) -> None: + """Test updating non-existent package raises error.""" + with pytest.raises(PackageNotFoundError): + update_package_version(empty_packages, "nonexistent", "1.0.0") + + def test_update_non_hub_package_version_raises(self, empty_packages: DbtPackages) -> None: + """Test updating non-hub package version raises error.""" + packages = add_git_package(empty_packages, "https://github.com/org/repo.git", "main") + + with pytest.raises(ValueError, match="not a hub package"): + update_package_version(packages, "https://github.com/org/repo.git", "v2.0.0") + + +class TestPackageHelpers: + """Tests for package helper functions.""" + + @pytest.fixture + def mixed_packages(self) -> DbtPackages: + """Create packages with mixed types.""" + return DbtPackages( + packages=[ + HubPackage(package="dbt-labs/dbt_utils", version=">=1.0.0"), + GitPackage(git="https://github.com/org/repo.git", revision="main"), + LocalPackage(local="../shared"), + ] + ) + + def test_get_package_identifiers(self, mixed_packages: DbtPackages) -> None: + """Test getting package identifiers.""" + identifiers = get_package_identifiers(mixed_packages) + + assert identifiers == [ + "dbt-labs/dbt_utils", + "https://github.com/org/repo.git", + "../shared", + ] + + def test_find_package_index(self, mixed_packages: DbtPackages) -> None: + """Test finding package index.""" + assert find_package_index(mixed_packages, "dbt-labs/dbt_utils") == 0 + assert find_package_index(mixed_packages, "https://github.com/org/repo.git") == 1 + assert find_package_index(mixed_packages, "../shared") == 2 + assert find_package_index(mixed_packages, "nonexistent") is None + + def test_has_package(self, mixed_packages: DbtPackages) -> None: + """Test checking if package exists.""" + assert has_package(mixed_packages, "dbt-labs/dbt_utils") is True + assert has_package(mixed_packages, "nonexistent") is False + + def test_get_package_display_info(self, mixed_packages: DbtPackages) -> None: + """Test getting package display info.""" + info = get_package_display_info(mixed_packages) + + assert len(info) == 3 + assert info[0] == ("dbt-labs/dbt_utils", "hub: >=1.0.0") + assert info[1] == ("https://github.com/org/repo.git", "git: main") + assert info[2] == ("../shared", "local") + + def test_get_package_display_info_with_subdirectory(self) -> None: + """Test display info for git package with subdirectory.""" + packages = DbtPackages( + packages=[GitPackage(git="https://github.com/org/repo.git", revision="main", subdirectory="pkg")] + ) + + info = get_package_display_info(packages) + + assert info[0] == ("https://github.com/org/repo.git", "git: main (pkg)") diff --git a/tests/unit/test_dbt_project_finder.py b/tests/unit/test_dbt_project_finder.py new file mode 100644 index 0000000..d9dea04 --- /dev/null +++ b/tests/unit/test_dbt_project_finder.py @@ -0,0 +1,229 @@ +"""Unit tests for dbt project finder module.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from brix.modules.dbt.project.finder import ( + EXCLUDE_DIRS, + _format_project_choice, + _should_exclude, + find_dbt_projects, + get_search_root, +) + + +class TestGetSearchRoot: + """Tests for get_search_root function.""" + + def test_in_git_repo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test returns git root when in a git repo.""" + git_root = tmp_path / "repo" + git_root.mkdir() + + with patch("subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + mock_run.return_value.stdout = str(git_root) + "\n" + + result = get_search_root() + + assert result == git_root + + def test_not_in_git_repo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test returns cwd when not in a git repo.""" + import subprocess + + monkeypatch.chdir(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, "git") + + result = get_search_root() + + assert result == tmp_path + + +class TestShouldExclude: + """Tests for _should_exclude function.""" + + def test_excludes_venv(self) -> None: + """Test excludes .venv directory.""" + path = Path("/project/.venv/some/path") + assert _should_exclude(path) is True + + def test_excludes_node_modules(self) -> None: + """Test excludes node_modules directory.""" + path = Path("/project/node_modules/package/file") + assert _should_exclude(path) is True + + def test_excludes_dbt_packages(self) -> None: + """Test excludes dbt_packages directory.""" + path = Path("/project/dbt_packages/dbt_utils/file") + assert _should_exclude(path) is True + + def test_excludes_target(self) -> None: + """Test excludes target directory.""" + path = Path("/project/target/compiled/file") + assert _should_exclude(path) is True + + def test_excludes_git(self) -> None: + """Test excludes .git directory.""" + path = Path("/project/.git/objects/file") + assert _should_exclude(path) is True + + def test_does_not_exclude_normal_path(self) -> None: + """Test does not exclude normal project paths.""" + path = Path("/project/models/staging/file.sql") + assert _should_exclude(path) is False + + def test_exclude_dirs_constant(self) -> None: + """Test EXCLUDE_DIRS contains expected values.""" + assert ".venv" in EXCLUDE_DIRS + assert "venv" in EXCLUDE_DIRS + assert "node_modules" in EXCLUDE_DIRS + assert "dbt_packages" in EXCLUDE_DIRS + assert "target" in EXCLUDE_DIRS + assert ".git" in EXCLUDE_DIRS + + +class TestFindDbtProjects: + """Tests for find_dbt_projects function.""" + + def test_finds_projects(self, tmp_path: Path) -> None: + """Test finds dbt projects.""" + # Create a dbt project + project_dir = tmp_path / "my_project" + project_dir.mkdir() + (project_dir / "dbt_project.yml").write_text("name: test\nprofile: default\n") + + projects = find_dbt_projects(tmp_path) + + assert len(projects) == 1 + assert projects[0] == (project_dir / "dbt_project.yml").resolve() + + def test_finds_multiple_projects(self, tmp_path: Path) -> None: + """Test finds multiple projects.""" + # Create two dbt projects + for name in ["project_a", "project_b"]: + project_dir = tmp_path / name + project_dir.mkdir() + (project_dir / "dbt_project.yml").write_text(f"name: {name}\nprofile: default\n") + + projects = find_dbt_projects(tmp_path) + + assert len(projects) == 2 + + def test_excludes_venv(self, tmp_path: Path) -> None: + """Test excludes projects in .venv.""" + # Create project in .venv (should be excluded) + venv_dir = tmp_path / ".venv" / "some_project" + venv_dir.mkdir(parents=True) + (venv_dir / "dbt_project.yml").write_text("name: test\nprofile: default\n") + + # Create normal project + project_dir = tmp_path / "my_project" + project_dir.mkdir() + (project_dir / "dbt_project.yml").write_text("name: test\nprofile: default\n") + + projects = find_dbt_projects(tmp_path) + + assert len(projects) == 1 + assert ".venv" not in str(projects[0]) + + def test_excludes_dbt_packages(self, tmp_path: Path) -> None: + """Test excludes projects in dbt_packages.""" + # Create project in dbt_packages (should be excluded) + pkgs_dir = tmp_path / "my_project" / "dbt_packages" / "dbt_utils" + pkgs_dir.mkdir(parents=True) + (pkgs_dir / "dbt_project.yml").write_text("name: dbt_utils\nprofile: default\n") + + # Create main project + project_dir = tmp_path / "my_project" + (project_dir / "dbt_project.yml").write_text("name: test\nprofile: default\n") + + projects = find_dbt_projects(tmp_path) + + # Should find only the main project, not the one in dbt_packages + assert len(projects) == 1 + assert projects[0] == (project_dir / "dbt_project.yml").resolve() + + def test_respects_max_depth(self, tmp_path: Path) -> None: + """Test respects max_depth parameter.""" + # Create deeply nested project + deep_dir = tmp_path / "a" / "b" / "c" / "d" / "e" / "project" + deep_dir.mkdir(parents=True) + (deep_dir / "dbt_project.yml").write_text("name: deep\nprofile: default\n") + + # Create shallow project + shallow_dir = tmp_path / "shallow" + shallow_dir.mkdir() + (shallow_dir / "dbt_project.yml").write_text("name: shallow\nprofile: default\n") + + # With max_depth=2, should only find shallow + projects = find_dbt_projects(tmp_path, max_depth=2) + + assert len(projects) == 1 + assert "shallow" in str(projects[0]) + + def test_returns_empty_if_none_found(self, tmp_path: Path) -> None: + """Test returns empty list if no projects found.""" + projects = find_dbt_projects(tmp_path) + + assert projects == [] + + def test_returns_sorted_paths(self, tmp_path: Path) -> None: + """Test returns paths sorted by path.""" + # Create projects in non-alphabetical order + for name in ["z_project", "a_project", "m_project"]: + project_dir = tmp_path / name + project_dir.mkdir() + (project_dir / "dbt_project.yml").write_text(f"name: {name}\nprofile: default\n") + + projects = find_dbt_projects(tmp_path) + + # Should be sorted alphabetically + assert "a_project" in str(projects[0]) + assert "m_project" in str(projects[1]) + assert "z_project" in str(projects[2]) + + def test_handles_nonexistent_root(self, tmp_path: Path) -> None: + """Test handles non-existent root gracefully.""" + nonexistent = tmp_path / "nonexistent" + + projects = find_dbt_projects(nonexistent) + + assert projects == [] + + +class TestFormatProjectChoice: + """Tests for _format_project_choice function.""" + + def test_formats_relative_path(self, tmp_path: Path) -> None: + """Test formats path relative to search root.""" + project_path = tmp_path / "projects" / "my_project" / "dbt_project.yml" + + result = _format_project_choice(project_path, tmp_path) + + assert result == "projects/my_project" + + def test_formats_root_project(self, tmp_path: Path) -> None: + """Test formats project at root.""" + project_dir = tmp_path / "my_project" + project_dir.mkdir() + project_path = project_dir / "dbt_project.yml" + + result = _format_project_choice(project_path, tmp_path) + + assert result == "my_project" + + def test_handles_path_outside_root(self, tmp_path: Path) -> None: + """Test handles path outside search root.""" + other_root = tmp_path / "other" + other_root.mkdir() + project_path = other_root / "project" / "dbt_project.yml" + + result = _format_project_choice(project_path, tmp_path / "different") + + # Should fall back to absolute path parent + assert str(project_path.parent) in result diff --git a/tests/unit/test_version_check.py b/tests/unit/test_version_check.py index 7683187..e461b09 100644 --- a/tests/unit/test_version_check.py +++ b/tests/unit/test_version_check.py @@ -149,6 +149,15 @@ def test_no_update_needed(self, temp_cache_dir, monkeypatch): result = check_for_updates() assert result is None + def test_installed_version_newer_than_cached(self, temp_cache_dir, monkeypatch): + """Ensure no update shown when installed version is newer than cached (e.g., 1.1.0 > 1.0.1).""" + monkeypatch.setattr(version_check, "__version__", "1.1.0") + cache = VersionCache(last_check=datetime.now(timezone.utc), latest_version="1.0.1") + temp_cache_dir.parent.mkdir(parents=True, exist_ok=True) + temp_cache_dir.write_text(cache.model_dump_json()) + result = check_for_updates() + assert result is None + def test_spawns_background_thread_when_stale(self, temp_cache_dir, monkeypatch): monkeypatch.setattr(version_check, "__version__", "1.0.0") old_time = datetime.now(timezone.utc) - CHECK_INTERVAL - timedelta(hours=1) diff --git a/uv.lock b/uv.lock index ec26e3d..de5789f 100644 --- a/uv.lock +++ b/uv.lock @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "brix" -version = "1.0.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "httpx" },