dbtcopy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ${{ matrix.os }}
12
+ strategy:
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest]
15
+ python-version: ["3.9", "3.12"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Set up Python ${{ matrix.python-version }}
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+
25
+ - name: Install dependencies
26
+ run: pip install -e ".[dev]"
27
+
28
+ - name: Run tests
29
+ run: pytest -v
@@ -0,0 +1,51 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ${{ matrix.os }}
10
+ strategy:
11
+ matrix:
12
+ os: [ubuntu-latest, macos-latest]
13
+ python-version: ["3.9", "3.12"]
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Set up Python ${{ matrix.python-version }}
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install dependencies
24
+ run: pip install -e ".[dev]"
25
+
26
+ - name: Run tests
27
+ run: pytest -v
28
+
29
+ publish:
30
+ needs: test
31
+ runs-on: ubuntu-latest
32
+ environment: pypi
33
+ permissions:
34
+ id-token: write
35
+
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+
39
+ - name: Set up Python 3.12
40
+ uses: actions/setup-python@v5
41
+ with:
42
+ python-version: "3.12"
43
+
44
+ - name: Install build
45
+ run: pip install build
46
+
47
+ - name: Build package
48
+ run: python -m build
49
+
50
+ - name: Publish to PyPI
51
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ *.egg
7
+ .pytest_cache/
8
+ .eggs/
dbtcopy-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tomiwa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dbtcopy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: dbtcopy
3
+ Version: 0.1.0
4
+ Summary: Compile a dbt model and copy the clean SQL to your clipboard
5
+ Project-URL: Homepage, https://github.com/tomiwa-dev/dbtcopy
6
+ Project-URL: Repository, https://github.com/tomiwa-dev/dbtcopy
7
+ Author: Tomiwa
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Database
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: click>=8.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: build; extra == 'dev'
24
+ Requires-Dist: pytest>=7.0; extra == 'dev'
25
+ Requires-Dist: twine; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # dbtcopy
29
+
30
+ Compile a dbt model and copy the clean SQL to your clipboard — no log noise, no thread counts, no adapter info.
31
+
32
+ `dbt compile` dumps a wall of logs to stdout, making it painful to grab just the SQL. `dbtcopy` runs the compile for you, reads the clean output from `target/compiled/`, and copies it straight to your clipboard.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install dbtcopy
38
+ ```
39
+
40
+ Requires Python 3.9+ and dbt already installed in your environment.
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ # Basic — compile and copy to clipboard
46
+ dbtcopy my_model
47
+
48
+ # With dbt target
49
+ dbtcopy my_model --target=prod
50
+
51
+ # With full-refresh
52
+ dbtcopy my_model --target=prod --full-refresh
53
+
54
+ # With dbt vars
55
+ dbtcopy my_model --target=prod --vars '{"start_date": "2024-01-01"}'
56
+
57
+ # Print to stdout instead of clipboard
58
+ dbtcopy my_model --print
59
+
60
+ # Copy AND print
61
+ dbtcopy my_model --print-and-copy
62
+
63
+ # Hide dbt compile output
64
+ dbtcopy my_model --quiet
65
+
66
+ # Skip compile, just grab existing compiled SQL
67
+ dbtcopy my_model --no-compile
68
+
69
+ # Specify dbt project/profiles directory
70
+ dbtcopy my_model --project-dir /path/to/project --profiles-dir /path/to/profiles
71
+ ```
72
+
73
+ All arguments after the model name are passed directly to `dbt compile`.
74
+
75
+ ## How it works
76
+
77
+ 1. Runs `dbt compile --select <model>` (output shown by default, use `--quiet` to hide)
78
+ 2. Finds `<model>.sql` in `target/compiled/` recursively
79
+ 3. Reads the file and copies the contents to your clipboard
80
+ 4. Prints a confirmation: `Copied 42 lines to clipboard (target/compiled/.../model.sql)`
81
+ 5. On compile failure, shows the actual dbt error message
82
+
83
+ ## Platform support
84
+
85
+ Clipboard works on:
86
+ - **macOS** — `pbcopy`
87
+ - **Linux (X11)** — `xclip`
88
+ - **Linux (Wayland)** — `wl-copy`
89
+ - **WSL / Windows** — `clip.exe`
90
+
91
+ If no clipboard tool is found, the SQL is printed to stdout as a fallback.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,68 @@
1
+ # dbtcopy
2
+
3
+ Compile a dbt model and copy the clean SQL to your clipboard — no log noise, no thread counts, no adapter info.
4
+
5
+ `dbt compile` dumps a wall of logs to stdout, making it painful to grab just the SQL. `dbtcopy` runs the compile for you, reads the clean output from `target/compiled/`, and copies it straight to your clipboard.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install dbtcopy
11
+ ```
12
+
13
+ Requires Python 3.9+ and dbt already installed in your environment.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # Basic — compile and copy to clipboard
19
+ dbtcopy my_model
20
+
21
+ # With dbt target
22
+ dbtcopy my_model --target=prod
23
+
24
+ # With full-refresh
25
+ dbtcopy my_model --target=prod --full-refresh
26
+
27
+ # With dbt vars
28
+ dbtcopy my_model --target=prod --vars '{"start_date": "2024-01-01"}'
29
+
30
+ # Print to stdout instead of clipboard
31
+ dbtcopy my_model --print
32
+
33
+ # Copy AND print
34
+ dbtcopy my_model --print-and-copy
35
+
36
+ # Hide dbt compile output
37
+ dbtcopy my_model --quiet
38
+
39
+ # Skip compile, just grab existing compiled SQL
40
+ dbtcopy my_model --no-compile
41
+
42
+ # Specify dbt project/profiles directory
43
+ dbtcopy my_model --project-dir /path/to/project --profiles-dir /path/to/profiles
44
+ ```
45
+
46
+ All arguments after the model name are passed directly to `dbt compile`.
47
+
48
+ ## How it works
49
+
50
+ 1. Runs `dbt compile --select <model>` (output shown by default, use `--quiet` to hide)
51
+ 2. Finds `<model>.sql` in `target/compiled/` recursively
52
+ 3. Reads the file and copies the contents to your clipboard
53
+ 4. Prints a confirmation: `Copied 42 lines to clipboard (target/compiled/.../model.sql)`
54
+ 5. On compile failure, shows the actual dbt error message
55
+
56
+ ## Platform support
57
+
58
+ Clipboard works on:
59
+ - **macOS** — `pbcopy`
60
+ - **Linux (X11)** — `xclip`
61
+ - **Linux (Wayland)** — `wl-copy`
62
+ - **WSL / Windows** — `clip.exe`
63
+
64
+ If no clipboard tool is found, the SQL is printed to stdout as a fallback.
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dbtcopy"
7
+ version = "0.1.0"
8
+ description = "Compile a dbt model and copy the clean SQL to your clipboard"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Tomiwa" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Database",
26
+ ]
27
+ dependencies = [
28
+ "click>=8.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=7.0",
34
+ "build",
35
+ "twine",
36
+ ]
37
+
38
+ [project.scripts]
39
+ dbtcopy = "dbtcopy.cli:main"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/dbtcopy"]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/tomiwa-dev/dbtcopy"
46
+ Repository = "https://github.com/tomiwa-dev/dbtcopy"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,66 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+ from dbtcopy.clipboard import copy_to_clipboard
6
+ from dbtcopy.compiler import (
7
+ CompileError,
8
+ DbtNotFoundError,
9
+ ModelNotFoundError,
10
+ compile_model,
11
+ find_compiled_sql,
12
+ )
13
+
14
+
15
+ @click.command(
16
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
17
+ )
18
+ @click.argument("model_name")
19
+ @click.option("--print", "-p", "print_sql", is_flag=True, help="Print compiled SQL to stdout instead of copying.")
20
+ @click.option("--print-and-copy", is_flag=True, help="Copy to clipboard AND print to stdout.")
21
+ @click.option("--no-compile", is_flag=True, help="Skip dbt compile, just find existing compiled SQL.")
22
+ @click.option("--project-dir", type=click.Path(), default=None, help="Path to dbt project directory.")
23
+ @click.option("--profiles-dir", type=click.Path(), default=None, help="Path to dbt profiles directory.")
24
+ @click.option("--quiet", "-q", is_flag=True, help="Hide dbt compile output.")
25
+ @click.pass_context
26
+ def main(ctx, model_name, print_sql, print_and_copy, no_compile, project_dir, profiles_dir, quiet):
27
+ """Compile a dbt model and copy the clean SQL to your clipboard.
28
+
29
+ All extra arguments after MODEL_NAME are passed directly to dbt compile.
30
+ """
31
+ extra_args = ctx.args
32
+
33
+ try:
34
+ if not no_compile:
35
+ compile_model(
36
+ model_name,
37
+ extra_args=extra_args,
38
+ project_dir=project_dir,
39
+ profiles_dir=profiles_dir,
40
+ quiet=quiet,
41
+ )
42
+
43
+ sql_path = find_compiled_sql(model_name, project_dir=project_dir)
44
+ sql = sql_path.read_text().strip()
45
+ line_count = len(sql.splitlines())
46
+ rel_path = sql_path
47
+
48
+ if print_sql:
49
+ print(sql)
50
+ elif print_and_copy:
51
+ copy_to_clipboard(sql)
52
+ print(sql)
53
+ click.echo(f"\u2713 Copied {line_count} lines to clipboard ({rel_path})", err=True)
54
+ else:
55
+ copy_to_clipboard(sql)
56
+ click.echo(f"\u2713 Copied {line_count} lines to clipboard ({rel_path})")
57
+
58
+ except DbtNotFoundError as e:
59
+ click.echo(f"Error: {e}", err=True)
60
+ sys.exit(1)
61
+ except CompileError as e:
62
+ click.echo(f"Error: {e}", err=True)
63
+ sys.exit(1)
64
+ except ModelNotFoundError as e:
65
+ click.echo(f"Error: {e}", err=True)
66
+ sys.exit(1)
@@ -0,0 +1,39 @@
1
+ import shutil
2
+ import subprocess
3
+ import sys
4
+
5
+
6
+ def detect_clipboard_tool():
7
+ """Detect the available clipboard tool for the current platform.
8
+
9
+ Returns a list of command args to pipe text into, or None if no tool found.
10
+ """
11
+ tools = [
12
+ ("pbcopy", ["pbcopy"]),
13
+ ("xclip", ["xclip", "-selection", "clipboard"]),
14
+ ("wl-copy", ["wl-copy"]),
15
+ ("clip.exe", ["clip.exe"]),
16
+ ]
17
+ for name, cmd in tools:
18
+ if shutil.which(name):
19
+ return cmd
20
+ return None
21
+
22
+
23
+ def copy_to_clipboard(text):
24
+ """Copy text to the system clipboard.
25
+
26
+ Returns True if copied successfully, False if no clipboard tool was found
27
+ (in which case the text is printed to stdout as a fallback).
28
+ """
29
+ cmd = detect_clipboard_tool()
30
+ if cmd is None:
31
+ print(
32
+ "Warning: no clipboard tool found. Printing SQL to stdout instead.",
33
+ file=sys.stderr,
34
+ )
35
+ print(text)
36
+ return False
37
+
38
+ subprocess.run(cmd, input=text.encode(), check=True)
39
+ return True
@@ -0,0 +1,90 @@
1
+ import shutil
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ class DbtNotFoundError(Exception):
8
+ pass
9
+
10
+
11
+ class CompileError(Exception):
12
+ pass
13
+
14
+
15
+ class ModelNotFoundError(Exception):
16
+ pass
17
+
18
+
19
+ def check_dbt_installed():
20
+ """Verify that dbt is available on PATH."""
21
+ if shutil.which("dbt") is None:
22
+ raise DbtNotFoundError(
23
+ "dbt is not installed or not on PATH. "
24
+ "Install it with: pip install dbt-core dbt-<adapter>"
25
+ )
26
+
27
+
28
+ def compile_model(model_name, extra_args=None, project_dir=None, profiles_dir=None, quiet=False):
29
+ """Run dbt compile for a single model.
30
+
31
+ Stdout is suppressed by default; pass verbose=True to show full dbt output.
32
+ Stderr is always shown so the user sees progress.
33
+ """
34
+ check_dbt_installed()
35
+
36
+ cmd = ["dbt", "compile", "--select", model_name]
37
+
38
+ if project_dir:
39
+ cmd.extend(["--project-dir", str(project_dir)])
40
+ if profiles_dir:
41
+ cmd.extend(["--profiles-dir", str(profiles_dir)])
42
+ if extra_args:
43
+ cmd.extend(extra_args)
44
+
45
+ result = subprocess.run(
46
+ cmd,
47
+ stdout=subprocess.PIPE if quiet else None,
48
+ stderr=subprocess.PIPE,
49
+ )
50
+
51
+ if result.returncode != 0:
52
+ output = result.stdout.decode().strip() if result.stdout else ""
53
+ err_output = result.stderr.decode().strip() if result.stderr else ""
54
+ combined = "\n".join(filter(None, [output, err_output]))
55
+ raise CompileError(
56
+ f"dbt compile failed (exit code {result.returncode}):\n{combined}"
57
+ )
58
+
59
+
60
+ def find_compiled_sql(model_name, project_dir=None):
61
+ """Find the compiled SQL file for a model in target/compiled/.
62
+
63
+ Searches recursively for <model_name>.sql under the target/compiled/ directory.
64
+ """
65
+ base = Path(project_dir) if project_dir else Path.cwd()
66
+ compiled_dir = base / "target" / "compiled"
67
+
68
+ if not compiled_dir.exists():
69
+ raise ModelNotFoundError(
70
+ f"Compiled directory not found: {compiled_dir}\n"
71
+ "Run dbt compile first, or check your --project-dir."
72
+ )
73
+
74
+ target_filename = f"{model_name}.sql"
75
+ matches = list(compiled_dir.rglob(target_filename))
76
+
77
+ if not matches:
78
+ raise ModelNotFoundError(
79
+ f"Could not find compiled SQL for model '{model_name}' "
80
+ f"in {compiled_dir}"
81
+ )
82
+
83
+ if len(matches) > 1:
84
+ paths = "\n ".join(str(m) for m in matches)
85
+ raise ModelNotFoundError(
86
+ f"Found multiple compiled files for '{model_name}':\n {paths}\n"
87
+ "Use a more specific model name."
88
+ )
89
+
90
+ return matches[0]
@@ -0,0 +1,62 @@
1
+ from unittest.mock import patch, MagicMock
2
+
3
+ from dbtcopy.clipboard import copy_to_clipboard, detect_clipboard_tool
4
+
5
+
6
+ class TestDetectClipboardTool:
7
+ def test_detects_pbcopy(self):
8
+ with patch("dbtcopy.clipboard.shutil.which") as mock_which:
9
+ mock_which.side_effect = lambda name: "/usr/bin/pbcopy" if name == "pbcopy" else None
10
+ result = detect_clipboard_tool()
11
+ assert result == ["pbcopy"]
12
+
13
+ def test_detects_xclip(self):
14
+ with patch("dbtcopy.clipboard.shutil.which") as mock_which:
15
+ mock_which.side_effect = lambda name: "/usr/bin/xclip" if name == "xclip" else None
16
+ result = detect_clipboard_tool()
17
+ assert result == ["xclip", "-selection", "clipboard"]
18
+
19
+ def test_detects_wl_copy(self):
20
+ with patch("dbtcopy.clipboard.shutil.which") as mock_which:
21
+ mock_which.side_effect = lambda name: "/usr/bin/wl-copy" if name == "wl-copy" else None
22
+ result = detect_clipboard_tool()
23
+ assert result == ["wl-copy"]
24
+
25
+ def test_detects_clip_exe(self):
26
+ with patch("dbtcopy.clipboard.shutil.which") as mock_which:
27
+ mock_which.side_effect = lambda name: "/mnt/c/Windows/system32/clip.exe" if name == "clip.exe" else None
28
+ result = detect_clipboard_tool()
29
+ assert result == ["clip.exe"]
30
+
31
+ def test_returns_none_when_no_tool(self):
32
+ with patch("dbtcopy.clipboard.shutil.which", return_value=None):
33
+ result = detect_clipboard_tool()
34
+ assert result is None
35
+
36
+ def test_priority_order(self):
37
+ """pbcopy should be detected first even if other tools exist."""
38
+ with patch("dbtcopy.clipboard.shutil.which") as mock_which:
39
+ mock_which.side_effect = lambda name: f"/usr/bin/{name}"
40
+ result = detect_clipboard_tool()
41
+ assert result == ["pbcopy"]
42
+
43
+
44
+ class TestCopyToClipboard:
45
+ def test_copies_text_successfully(self):
46
+ with patch("dbtcopy.clipboard.detect_clipboard_tool", return_value=["pbcopy"]):
47
+ with patch("dbtcopy.clipboard.subprocess.run") as mock_run:
48
+ result = copy_to_clipboard("SELECT 1")
49
+ assert result is True
50
+ mock_run.assert_called_once_with(
51
+ ["pbcopy"],
52
+ input=b"SELECT 1",
53
+ check=True,
54
+ )
55
+
56
+ def test_falls_back_to_stdout_when_no_tool(self, capsys):
57
+ with patch("dbtcopy.clipboard.detect_clipboard_tool", return_value=None):
58
+ result = copy_to_clipboard("SELECT 1")
59
+ assert result is False
60
+ captured = capsys.readouterr()
61
+ assert "SELECT 1" in captured.out
62
+ assert "Warning" in captured.err
@@ -0,0 +1,171 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+ from unittest.mock import patch, MagicMock
5
+
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from dbtcopy.compiler import (
10
+ CompileError,
11
+ DbtNotFoundError,
12
+ ModelNotFoundError,
13
+ check_dbt_installed,
14
+ compile_model,
15
+ find_compiled_sql,
16
+ )
17
+ from dbtcopy.cli import main
18
+
19
+
20
+ class TestCheckDbtInstalled:
21
+ def test_raises_when_dbt_not_found(self):
22
+ with patch("dbtcopy.compiler.shutil.which", return_value=None):
23
+ with pytest.raises(DbtNotFoundError):
24
+ check_dbt_installed()
25
+
26
+ def test_passes_when_dbt_found(self):
27
+ with patch("dbtcopy.compiler.shutil.which", return_value="/usr/local/bin/dbt"):
28
+ check_dbt_installed()
29
+
30
+
31
+ class TestCompileModel:
32
+ def test_runs_dbt_compile(self):
33
+ with patch("dbtcopy.compiler.shutil.which", return_value="/usr/local/bin/dbt"):
34
+ with patch("dbtcopy.compiler.subprocess.run") as mock_run:
35
+ mock_run.return_value = MagicMock(returncode=0)
36
+ compile_model("my_model")
37
+ args = mock_run.call_args[0][0]
38
+ assert args[:4] == ["dbt", "compile", "--select", "my_model"]
39
+
40
+ def test_passes_extra_args(self):
41
+ with patch("dbtcopy.compiler.shutil.which", return_value="/usr/local/bin/dbt"):
42
+ with patch("dbtcopy.compiler.subprocess.run") as mock_run:
43
+ mock_run.return_value = MagicMock(returncode=0)
44
+ compile_model("my_model", extra_args=["--target=prod", "--full-refresh"])
45
+ args = mock_run.call_args[0][0]
46
+ assert "--target=prod" in args
47
+ assert "--full-refresh" in args
48
+
49
+ def test_passes_project_dir(self):
50
+ with patch("dbtcopy.compiler.shutil.which", return_value="/usr/local/bin/dbt"):
51
+ with patch("dbtcopy.compiler.subprocess.run") as mock_run:
52
+ mock_run.return_value = MagicMock(returncode=0)
53
+ compile_model("my_model", project_dir="/some/path")
54
+ args = mock_run.call_args[0][0]
55
+ assert "--project-dir" in args
56
+ assert "/some/path" in args
57
+
58
+ def test_default_shows_stdout(self):
59
+ with patch("dbtcopy.compiler.shutil.which", return_value="/usr/local/bin/dbt"):
60
+ with patch("dbtcopy.compiler.subprocess.run") as mock_run:
61
+ mock_run.return_value = MagicMock(returncode=0)
62
+ compile_model("my_model")
63
+ assert mock_run.call_args[1]["stdout"] is None
64
+
65
+ def test_quiet_suppresses_stdout(self):
66
+ with patch("dbtcopy.compiler.shutil.which", return_value="/usr/local/bin/dbt"):
67
+ with patch("dbtcopy.compiler.subprocess.run") as mock_run:
68
+ mock_run.return_value = MagicMock(returncode=0)
69
+ compile_model("my_model", quiet=True)
70
+ assert mock_run.call_args[1]["stdout"] == subprocess.PIPE
71
+
72
+ def test_raises_on_compile_failure(self):
73
+ with patch("dbtcopy.compiler.shutil.which", return_value="/usr/local/bin/dbt"):
74
+ with patch("dbtcopy.compiler.subprocess.run") as mock_run:
75
+ mock_run.return_value = MagicMock(
76
+ returncode=1,
77
+ stdout=b"Compilation Error: something went wrong",
78
+ stderr=b"",
79
+ )
80
+ with pytest.raises(CompileError, match="something went wrong"):
81
+ compile_model("my_model")
82
+
83
+
84
+ class TestFindCompiledSql:
85
+ def test_finds_sql_file(self, tmp_path):
86
+ compiled_dir = tmp_path / "target" / "compiled" / "project" / "models"
87
+ compiled_dir.mkdir(parents=True)
88
+ sql_file = compiled_dir / "my_model.sql"
89
+ sql_file.write_text("SELECT 1")
90
+
91
+ result = find_compiled_sql("my_model", project_dir=str(tmp_path))
92
+ assert result == sql_file
93
+
94
+ def test_finds_nested_sql_file(self, tmp_path):
95
+ compiled_dir = tmp_path / "target" / "compiled" / "project" / "models" / "staging"
96
+ compiled_dir.mkdir(parents=True)
97
+ sql_file = compiled_dir / "my_model.sql"
98
+ sql_file.write_text("SELECT 1")
99
+
100
+ result = find_compiled_sql("my_model", project_dir=str(tmp_path))
101
+ assert result == sql_file
102
+
103
+ def test_raises_when_compiled_dir_missing(self, tmp_path):
104
+ with pytest.raises(ModelNotFoundError, match="Compiled directory not found"):
105
+ find_compiled_sql("my_model", project_dir=str(tmp_path))
106
+
107
+ def test_raises_when_model_not_found(self, tmp_path):
108
+ compiled_dir = tmp_path / "target" / "compiled"
109
+ compiled_dir.mkdir(parents=True)
110
+
111
+ with pytest.raises(ModelNotFoundError, match="Could not find"):
112
+ find_compiled_sql("my_model", project_dir=str(tmp_path))
113
+
114
+ def test_raises_on_multiple_matches(self, tmp_path):
115
+ for subdir in ["models", "snapshots"]:
116
+ d = tmp_path / "target" / "compiled" / "project" / subdir
117
+ d.mkdir(parents=True)
118
+ (d / "my_model.sql").write_text("SELECT 1")
119
+
120
+ with pytest.raises(ModelNotFoundError, match="multiple"):
121
+ find_compiled_sql("my_model", project_dir=str(tmp_path))
122
+
123
+
124
+ class TestCli:
125
+ def test_help(self):
126
+ runner = CliRunner()
127
+ result = runner.invoke(main, ["--help"])
128
+ assert result.exit_code == 0
129
+ assert "MODEL_NAME" in result.output
130
+
131
+ def test_no_compile_and_print(self, tmp_path):
132
+ compiled_dir = tmp_path / "target" / "compiled" / "project" / "models"
133
+ compiled_dir.mkdir(parents=True)
134
+ sql_file = compiled_dir / "my_model.sql"
135
+ sql_file.write_text("SELECT 1\nFROM table")
136
+
137
+ runner = CliRunner()
138
+ result = runner.invoke(
139
+ main,
140
+ ["my_model", "--no-compile", "--print", f"--project-dir={tmp_path}"],
141
+ )
142
+ assert result.exit_code == 0
143
+ assert "SELECT 1" in result.output
144
+
145
+ def test_no_compile_copies_to_clipboard(self, tmp_path):
146
+ compiled_dir = tmp_path / "target" / "compiled" / "project" / "models"
147
+ compiled_dir.mkdir(parents=True)
148
+ sql_file = compiled_dir / "my_model.sql"
149
+ sql_file.write_text("SELECT 1\nFROM table")
150
+
151
+ runner = CliRunner()
152
+ with patch("dbtcopy.cli.copy_to_clipboard", return_value=True) as mock_copy:
153
+ result = runner.invoke(
154
+ main,
155
+ ["my_model", "--no-compile", f"--project-dir={tmp_path}"],
156
+ )
157
+ assert result.exit_code == 0
158
+ mock_copy.assert_called_once()
159
+ assert "Copied 2 lines" in result.output
160
+
161
+ def test_error_when_model_not_found(self, tmp_path):
162
+ compiled_dir = tmp_path / "target" / "compiled"
163
+ compiled_dir.mkdir(parents=True)
164
+
165
+ runner = CliRunner()
166
+ result = runner.invoke(
167
+ main,
168
+ ["nonexistent", "--no-compile", f"--project-dir={tmp_path}"],
169
+ )
170
+ assert result.exit_code == 1
171
+ assert "Could not find" in result.output