Skip to content

Commit 7a19d80

Browse files
committed
feat: add --project-dir option to dbt-score lint
Allow linting a dbt project that is not the current working directory, similar to dbt's own `--project-dir` option. The directory is used to locate `manifest.json` (unless `--manifest` is given) and is passed on to `dbt parse` and `dbt ls` when dbt is invoked. Closes #67
1 parent 392a6a3 commit 7a19d80

11 files changed

Lines changed: 300 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
- Add `--project-dir` option to `dbt-score lint` to lint a dbt project located
12+
outside of the current working directory (#67).
13+
1114
## [0.19.0] - 2026-10-07
1215

1316
- Drop support for Python 3.10. The minimum required version is now 3.11.

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ dbt-score lint --exclude my_model+
6969

7070
# Auto-generate manifest (via `dbt parse`) and lint
7171
dbt-score lint --run-dbt-parse
72+
73+
# Lint a dbt project in another directory
74+
dbt-score lint --project-dir path/to/dbt_project --run-dbt-parse
7275
```
7376

7477
### Example Output

‎docs/get_started.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ It's also possible to automatically run `dbt parse`, to generate the
4040
dbt-score lint --run-dbt-parse
4141
```
4242

43+
To lint a dbt project located outside of the current working directory, use the
44+
`--project-dir` option. Similar to dbt, `dbt-score` will look for the project
45+
and its `manifest.json` in that directory, and run dbt from there:
46+
47+
```shell
48+
dbt-score lint --project-dir path/to/dbt_project --run-dbt-parse
49+
```
50+
4351
To lint only a selection of dbt entities, the argument `--select` can be used.
4452
It accepts any
4553
[dbt node selection syntax](https://docs.getdbt.com/reference/node-selection/syntax):

‎src/dbt_score/cli.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,20 @@ def cli() -> None:
7373
default=None,
7474
multiple=True,
7575
)
76+
@click.option(
77+
"--project-dir",
78+
help="Directory of the dbt project. Used to locate the manifest and to run "
79+
"dbt. Defaults to the current working directory.",
80+
type=click.Path(exists=True, file_okay=False, path_type=Path),
81+
default=None,
82+
)
7683
@click.option(
7784
"--manifest",
7885
"-m",
79-
help="Manifest filepath.",
86+
help="Manifest filepath. Defaults to `manifest.json` in the target directory "
87+
"of the dbt project.",
8088
type=click.Path(path_type=Path),
81-
default=get_default_manifest_path(),
89+
default=None,
8290
)
8391
@click.option(
8492
"--run-dbt-parse",
@@ -129,7 +137,8 @@ def lint( # noqa: PLR0913, C901, PLR0912
129137
exclude: tuple[str, ...],
130138
namespace: list[str],
131139
disabled_rule: list[str],
132-
manifest: Path,
140+
project_dir: Path | None,
141+
manifest: Path | None,
133142
run_dbt_parse: bool,
134143
fail_project_under: float | None,
135144
fail_any_item_under: float | None,
@@ -143,6 +152,8 @@ def lint( # noqa: PLR0913, C901, PLR0912
143152
)
144153
if manifest_provided and run_dbt_parse:
145154
raise click.UsageError("--run-dbt-parse cannot be used with --manifest.")
155+
if manifest is None:
156+
manifest = get_default_manifest_path(project_dir)
146157

147158
config = Config()
148159
config.load()
@@ -161,13 +172,14 @@ def lint( # noqa: PLR0913, C901, PLR0912
161172

162173
try:
163174
if run_dbt_parse:
164-
dbt_parse()
175+
dbt_parse(project_dir=project_dir)
165176
evaluation = lint_dbt_project(
166177
manifest_path=manifest,
167178
config=config,
168179
format=format,
169180
select=select,
170181
exclude=exclude,
182+
project_dir=project_dir,
171183
)
172184

173185
except FileNotFoundError:

‎src/dbt_score/dbt_utils.py‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,25 @@ def _disable_dbt_stdout() -> Iterator[None]:
6565

6666

6767
@dbt_required
68-
def dbt_parse() -> "dbtRunnerResult":
68+
def dbt_parse(project_dir: Path | None = None) -> "dbtRunnerResult":
6969
"""Parse a dbt project.
7070
71+
Args:
72+
project_dir: The dbt project directory. Defaults to the directory dbt
73+
would use on its own, i.e. the current working directory.
74+
7175
Returns:
7276
The dbt parse run result.
7377
7478
Raises:
7579
DbtParseException: dbt parse failed.
7680
"""
81+
cmd = ["parse"]
82+
if project_dir is not None:
83+
cmd += ["--project-dir", str(project_dir)]
84+
7785
with _disable_dbt_stdout():
78-
result: "dbtRunnerResult" = dbtRunner().invoke(["parse"])
86+
result: "dbtRunnerResult" = dbtRunner().invoke(cmd)
7987

8088
if not result.success:
8189
raise DbtParseException(root_cause=result.exception)
@@ -85,7 +93,9 @@ def dbt_parse() -> "dbtRunnerResult":
8593

8694
@dbt_required
8795
def dbt_ls(
88-
select: Iterable[str] | None, exclude: Iterable[str] | None = None
96+
select: Iterable[str] | None,
97+
exclude: Iterable[str] | None = None,
98+
project_dir: Path | None = None,
8999
) -> Iterable[str]:
90100
"""Run dbt ls."""
91101
cmd = [
@@ -103,6 +113,8 @@ def dbt_ls(
103113
cmd += ["--select", *select]
104114
if exclude:
105115
cmd += ["--exclude", *exclude]
116+
if project_dir is not None:
117+
cmd += ["--project-dir", str(project_dir)]
106118

107119
with _disable_dbt_stdout():
108120
result: "dbtRunnerResult" = dbtRunner().invoke(cmd)
@@ -114,11 +126,17 @@ def dbt_ls(
114126
return selected
115127

116128

117-
def get_default_manifest_path() -> Path:
118-
"""Get the manifest path."""
119-
return (
120-
Path().cwd()
121-
/ os.getenv("DBT_PROJECT_DIR", "")
122-
/ os.getenv("DBT_TARGET_DIR", "target")
123-
/ "manifest.json"
124-
)
129+
def get_default_manifest_path(project_dir: Path | None = None) -> Path:
130+
"""Get the manifest path.
131+
132+
Args:
133+
project_dir: The dbt project directory. Defaults to the current working
134+
directory, combined with the `DBT_PROJECT_DIR` environment variable
135+
if it is set.
136+
137+
Returns:
138+
The path of `manifest.json` in the target directory of the project.
139+
"""
140+
if project_dir is None:
141+
project_dir = Path.cwd() / os.getenv("DBT_PROJECT_DIR", "")
142+
return project_dir / os.getenv("DBT_TARGET_DIR", "target") / "manifest.json"

‎src/dbt_score/lint.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def lint_dbt_project(
2020
format: Literal["plain", "manifest", "ascii", "json"],
2121
select: Iterable[str] | None = None,
2222
exclude: Iterable[str] | None = None,
23+
project_dir: Path | None = None,
2324
) -> Evaluation:
2425
"""Lint dbt manifest."""
2526
if not manifest_path.exists():
@@ -28,7 +29,9 @@ def lint_dbt_project(
2829
rule_registry = RuleRegistry(config)
2930
rule_registry.load_all()
3031

31-
manifest_loader = ManifestLoader(manifest_path, select=select, exclude=exclude)
32+
manifest_loader = ManifestLoader(
33+
manifest_path, select=select, exclude=exclude, project_dir=project_dir
34+
)
3235

3336
formatters = {
3437
"plain": HumanReadableFormatter,

‎src/dbt_score/models.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -741,14 +741,18 @@ def __init__(
741741
file_path: Path,
742742
select: Iterable[str] | None = None,
743743
exclude: Iterable[str] | None = None,
744+
project_dir: Path | None = None,
744745
):
745746
"""Initialize the ManifestLoader.
746747
747748
Args:
748749
file_path: The file path of the JSON manifest.
749750
select: An optional dbt selection.
750751
exclude: An optional dbt exclusion.
752+
project_dir: An optional dbt project directory, used when the
753+
selection requires running dbt.
751754
"""
755+
self.project_dir = project_dir
752756
self.raw_manifest = json.loads(file_path.read_text(encoding="utf-8"))
753757
self.project_name = self.raw_manifest["metadata"]["project_name"]
754758
self.raw_nodes = {
@@ -913,7 +917,7 @@ def _filter_evaluables(
913917

914918
else:
915919
# Use dbt's implementation of --select and --exclude
916-
result_list = dbt_ls(select, exclude)
920+
result_list = dbt_ls(select, exclude, project_dir=self.project_dir)
917921

918922
self.models = {k: m for k, m in self.models.items() if m.name in result_list}
919923
self.sources = {

‎tests/test_cli.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Test the CLI."""
22

3+
import shutil
4+
from pathlib import Path
35
from unittest.mock import MagicMock, patch
46

57
from click.testing import CliRunner
@@ -63,6 +65,92 @@ def test_lint_unparseable_manifest(tmp_path, caplog):
6365
assert "dbt's manifest.json could not be parsed" in caplog.text
6466

6567

68+
def passing_evaluation() -> MagicMock:
69+
"""Return a mock evaluation with passing scores."""
70+
mock_eval = MagicMock()
71+
mock_eval.project_score = Score(10.0, "🥇")
72+
mock_eval.scores = {}
73+
return mock_eval
74+
75+
76+
def test_lint_project_dir(tmp_path, manifest_path):
77+
"""Test lint with a project directory, which is used to find the manifest."""
78+
runner = CliRunner()
79+
project_dir = tmp_path / "my_project"
80+
(project_dir / "target").mkdir(parents=True)
81+
shutil.copy(manifest_path, project_dir / "target" / "manifest.json")
82+
83+
with (
84+
patch("dbt_score.cli.Config._load_toml_file"),
85+
patch("dbt_score.cli.lint_dbt_project") as mock_lint_dbt_project,
86+
):
87+
mock_lint_dbt_project.return_value = passing_evaluation()
88+
result = runner.invoke(
89+
lint, ["--project-dir", str(project_dir)], catch_exceptions=False
90+
)
91+
92+
assert result.exit_code == 0
93+
assert mock_lint_dbt_project.call_args.kwargs["manifest_path"] == (
94+
project_dir / "target" / "manifest.json"
95+
)
96+
assert mock_lint_dbt_project.call_args.kwargs["project_dir"] == project_dir
97+
98+
99+
def test_lint_project_dir_with_manifest(tmp_path, manifest_path):
100+
"""Test lint with a project directory and an explicit manifest."""
101+
runner = CliRunner()
102+
103+
with (
104+
patch("dbt_score.cli.Config._load_toml_file"),
105+
patch("dbt_score.cli.lint_dbt_project") as mock_lint_dbt_project,
106+
):
107+
mock_lint_dbt_project.return_value = passing_evaluation()
108+
result = runner.invoke(
109+
lint,
110+
["--project-dir", str(tmp_path), "--manifest", str(manifest_path)],
111+
catch_exceptions=False,
112+
)
113+
114+
assert result.exit_code == 0
115+
assert mock_lint_dbt_project.call_args.kwargs["manifest_path"] == Path(
116+
manifest_path
117+
)
118+
assert mock_lint_dbt_project.call_args.kwargs["project_dir"] == tmp_path
119+
120+
121+
def test_lint_project_dir_run_dbt_parse(tmp_path):
122+
"""Test lint with a project directory and dbt parse."""
123+
runner = CliRunner()
124+
project_dir = tmp_path / "my_project"
125+
project_dir.mkdir()
126+
127+
with (
128+
patch("dbt_score.cli.Config._load_toml_file"),
129+
patch("dbt_score.cli.dbt_parse") as mock_dbt_parse,
130+
patch("dbt_score.cli.lint_dbt_project") as mock_lint_dbt_project,
131+
):
132+
mock_lint_dbt_project.return_value = passing_evaluation()
133+
result = runner.invoke(
134+
lint,
135+
["--project-dir", str(project_dir), "--run-dbt-parse"],
136+
catch_exceptions=False,
137+
)
138+
139+
assert result.exit_code == 0
140+
mock_dbt_parse.assert_called_once_with(project_dir=project_dir)
141+
142+
143+
def test_lint_project_dir_non_existing(tmp_path):
144+
"""Test lint with a non-existing project directory."""
145+
runner = CliRunner()
146+
147+
with patch("dbt_score.cli.Config._load_toml_file"):
148+
result = runner.invoke(lint, ["--project-dir", str(tmp_path / "nope")])
149+
150+
assert result.exit_code == 2
151+
assert "does not exist" in result.output
152+
153+
66154
def test_lint_dbt_parse_exception(caplog):
67155
"""Test lint with a dbt parse error."""
68156
runner = CliRunner()

0 commit comments

Comments
 (0)