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.
- dbtcopy-0.1.0/.github/workflows/ci.yml +29 -0
- dbtcopy-0.1.0/.github/workflows/publish.yml +51 -0
- dbtcopy-0.1.0/.gitignore +8 -0
- dbtcopy-0.1.0/LICENSE +21 -0
- dbtcopy-0.1.0/PKG-INFO +95 -0
- dbtcopy-0.1.0/README.md +68 -0
- dbtcopy-0.1.0/pyproject.toml +46 -0
- dbtcopy-0.1.0/src/dbtcopy/__init__.py +1 -0
- dbtcopy-0.1.0/src/dbtcopy/cli.py +66 -0
- dbtcopy-0.1.0/src/dbtcopy/clipboard.py +39 -0
- dbtcopy-0.1.0/src/dbtcopy/compiler.py +90 -0
- dbtcopy-0.1.0/tests/test_clipboard.py +62 -0
- dbtcopy-0.1.0/tests/test_compiler.py +171 -0
|
@@ -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
|
dbtcopy-0.1.0/.gitignore
ADDED
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
|
dbtcopy-0.1.0/README.md
ADDED
|
@@ -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
|