slimtest 0.1.0__py3-none-any.whl
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.
- slimtest/__init__.py +13 -0
- slimtest/cli.py +178 -0
- slimtest/compile.py +268 -0
- slimtest/config.py +57 -0
- slimtest/dbt_project.py +109 -0
- slimtest/dbt_runner.py +124 -0
- slimtest/deep_merge.py +34 -0
- slimtest/expand.py +141 -0
- slimtest/factory.py +191 -0
- slimtest/manifest.py +211 -0
- slimtest/model_parser.py +197 -0
- slimtest/parametrize.py +119 -0
- slimtest/result_parser.py +127 -0
- slimtest/runner.py +140 -0
- slimtest/scenario.py +60 -0
- slimtest/schema.py +182 -0
- slimtest/selector.py +52 -0
- slimtest-0.1.0.dist-info/METADATA +375 -0
- slimtest-0.1.0.dist-info/RECORD +22 -0
- slimtest-0.1.0.dist-info/WHEEL +4 -0
- slimtest-0.1.0.dist-info/entry_points.txt +2 -0
- slimtest-0.1.0.dist-info/licenses/LICENSE +21 -0
slimtest/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""slimtest — factory + trait DSL on top of dbt unit tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = _version("slimtest")
|
|
10
|
+
except PackageNotFoundError: # not installed (e.g. running from a clean source tree)
|
|
11
|
+
__version__ = "0.0.0+unknown"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
slimtest/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""slimtest CLI entry point.
|
|
2
|
+
|
|
3
|
+
`compile` is fully wired; `unittest` is still a stub pending the dbt
|
|
4
|
+
subprocess layer (next phase).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .compile import CompileResult, compile_project
|
|
16
|
+
from .factory import SlimTestError
|
|
17
|
+
from .runner import EnrichedOutcome, UnittestResult, unittest_project
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="slimtest",
|
|
21
|
+
help="Factory + trait DSL on top of dbt unit tests.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version_callback(value: bool) -> None: # noqa: FBT001 -- typer convention
|
|
28
|
+
if value:
|
|
29
|
+
typer.echo(__version__)
|
|
30
|
+
raise typer.Exit
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ProjectDirOption = Annotated[
|
|
34
|
+
Path,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"--project-dir",
|
|
37
|
+
help="Path to the dbt project root (defaults to the current directory).",
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
SelectOption = Annotated[
|
|
42
|
+
str | None,
|
|
43
|
+
typer.Option(
|
|
44
|
+
"--select",
|
|
45
|
+
help="Subset of models or tests to compile/run (dbt-style selector).",
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
VerboseOption = Annotated[
|
|
50
|
+
bool,
|
|
51
|
+
typer.Option(
|
|
52
|
+
"--verbose",
|
|
53
|
+
"-v",
|
|
54
|
+
help=(
|
|
55
|
+
"Also print INFO-level notices (e.g. every auto-injected upstream "
|
|
56
|
+
"row). Warnings are always shown."
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.callback()
|
|
63
|
+
def _root(
|
|
64
|
+
version: Annotated[ # noqa: ARG001 -- handled by callback
|
|
65
|
+
bool,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--version",
|
|
68
|
+
callback=_version_callback,
|
|
69
|
+
is_eager=True,
|
|
70
|
+
help="Print the slimtest version and exit.",
|
|
71
|
+
),
|
|
72
|
+
] = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Top-level CLI options."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def compile( # noqa: A001 -- shadowing builtin is fine for a CLI verb
|
|
79
|
+
project_dir: ProjectDirOption = Path("."),
|
|
80
|
+
select: SelectOption = None,
|
|
81
|
+
verbose: VerboseOption = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Expand slimtest extension YAML into standard dbt unit_tests YAML.
|
|
84
|
+
|
|
85
|
+
Writes generated artefacts under `target/slimtest/` and does NOT
|
|
86
|
+
invoke dbt. Intended for debugging and inspection.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
result = compile_project(project_dir, select=select)
|
|
90
|
+
except SlimTestError as exc:
|
|
91
|
+
typer.echo(f"[slimtest] error: {exc}", err=True)
|
|
92
|
+
raise typer.Exit(code=1) from exc
|
|
93
|
+
|
|
94
|
+
typer.echo(f"[slimtest] compiled {result.test_count} test(s):")
|
|
95
|
+
for path in result.generated_files:
|
|
96
|
+
rel = path.relative_to(result.project_root)
|
|
97
|
+
typer.echo(f" -> {rel}")
|
|
98
|
+
rel_map = result.source_map_path.relative_to(result.project_root)
|
|
99
|
+
typer.echo(f" -> {rel_map}")
|
|
100
|
+
_print_diagnostics(result, verbose=verbose)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def unittest(
|
|
105
|
+
project_dir: ProjectDirOption = Path("."),
|
|
106
|
+
select: SelectOption = None,
|
|
107
|
+
verbose: VerboseOption = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Compile + run the resulting unit tests via `dbt test`."""
|
|
110
|
+
try:
|
|
111
|
+
result = unittest_project(project_dir, select=select)
|
|
112
|
+
except SlimTestError as exc:
|
|
113
|
+
typer.echo(f"[slimtest] error: {exc}", err=True)
|
|
114
|
+
raise typer.Exit(code=1) from exc
|
|
115
|
+
|
|
116
|
+
_print_unittest_result(result, verbose=verbose)
|
|
117
|
+
raise typer.Exit(code=0 if result.ok else 1)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# -- output helpers --------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _print_diagnostics(compile_result: CompileResult, *, verbose: bool) -> None:
|
|
124
|
+
"""Emit warnings always; emit notices only when verbose is set."""
|
|
125
|
+
for warning in compile_result.warnings:
|
|
126
|
+
typer.echo(f"[slimtest] warning: {warning}", err=True)
|
|
127
|
+
if verbose:
|
|
128
|
+
for notice in compile_result.notices:
|
|
129
|
+
typer.echo(f"[slimtest] info: {notice}", err=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _print_unittest_result(result: UnittestResult, *, verbose: bool = False) -> None:
|
|
133
|
+
n = result.compile.test_count
|
|
134
|
+
typer.echo(f"[slimtest] compiled {n} test(s)")
|
|
135
|
+
_print_diagnostics(result.compile, verbose=verbose)
|
|
136
|
+
|
|
137
|
+
if not result.parse_result.ok:
|
|
138
|
+
typer.echo(
|
|
139
|
+
f"[slimtest] warning: `dbt parse` exited {result.parse_result.exit_code}",
|
|
140
|
+
err=True,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if n == 0:
|
|
144
|
+
typer.echo("[slimtest] no slimtest tests to run.")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
failed = [e for e in result.outcomes if e.outcome.status != "pass"]
|
|
148
|
+
typer.echo(
|
|
149
|
+
f"[slimtest] {result.summary.passed}/{result.summary.total} passed; "
|
|
150
|
+
f"{result.summary.failed} failed, "
|
|
151
|
+
f"{result.summary.errored} errored, "
|
|
152
|
+
f"{result.summary.skipped} skipped."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
for entry in failed:
|
|
156
|
+
_print_failure(entry)
|
|
157
|
+
|
|
158
|
+
if result.summary.total == 0 and not result.test_result.ok:
|
|
159
|
+
# dbt failed before producing run_results.json -- surface stderr.
|
|
160
|
+
typer.echo(result.test_result.stderr, err=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _print_failure(entry: EnrichedOutcome) -> None:
|
|
164
|
+
label = entry.original_name or entry.outcome.name
|
|
165
|
+
if entry.source_file is not None and entry.source_line is not None:
|
|
166
|
+
location = f"{entry.source_file}:{entry.source_line}"
|
|
167
|
+
else:
|
|
168
|
+
location = "<source location unknown>"
|
|
169
|
+
typer.echo(
|
|
170
|
+
f" {entry.outcome.status.upper()}: {label} ({location})",
|
|
171
|
+
err=True,
|
|
172
|
+
)
|
|
173
|
+
if entry.outcome.message:
|
|
174
|
+
for line in entry.outcome.message.splitlines():
|
|
175
|
+
typer.echo(f" {line}", err=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
__all__ = ["app"]
|
slimtest/compile.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Compile slimtest extension records into dbt-standard `unit_tests` YAML.
|
|
2
|
+
|
|
3
|
+
End-to-end flow per design.md §8.1:
|
|
4
|
+
|
|
5
|
+
1. Load `slimtest.yml` (defaults if absent).
|
|
6
|
+
2. Build a `FactoryRegistry` from the factories_path.
|
|
7
|
+
3. Walk `models/**/*.yml` and collect every `meta.slimtest.unit_tests`
|
|
8
|
+
entry, paired with its source line.
|
|
9
|
+
4. For each entry, expand factory refs into literal rows.
|
|
10
|
+
5. Group by model, write `target/slimtest/<model>.generated.yml`.
|
|
11
|
+
6. Emit `target/slimtest/source_map.json` mapping generated test names
|
|
12
|
+
back to source (file, line).
|
|
13
|
+
|
|
14
|
+
Upstream `ref()` vs `source()` disambiguation happens via the dbt
|
|
15
|
+
manifest. If a manifest is supplied (or auto-loaded from
|
|
16
|
+
`target/manifest.json`), each upstream identifier is rendered as the
|
|
17
|
+
right form; if not, everything falls back to `ref('<name>')` and the
|
|
18
|
+
caller is on the hook for any source-shaped tests it tries to run.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from io import StringIO
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ruamel.yaml import YAML
|
|
30
|
+
|
|
31
|
+
from .config import load_config
|
|
32
|
+
from .dbt_project import check_generated_path_in_model_paths, load_dbt_project
|
|
33
|
+
from .expand import ExpandedUnitTest, expand_unit_test
|
|
34
|
+
from .factory import FactoryRegistry, SlimTestError
|
|
35
|
+
from .manifest import Manifest, UpstreamRef, try_load_manifest
|
|
36
|
+
from .model_parser import ParsedUnitTest, find_slimtest_tests
|
|
37
|
+
from .parametrize import expand_parametrize
|
|
38
|
+
from .scenario import apply_scenario
|
|
39
|
+
from .schema import SlimTestConfig
|
|
40
|
+
from .selector import filter_tests
|
|
41
|
+
|
|
42
|
+
GENERATED_FILE_SUFFIX = ".generated.yml"
|
|
43
|
+
SOURCE_MAP_FILENAME = "source_map.json"
|
|
44
|
+
GENERATED_HEADER = "# Generated by slimtest. DO NOT EDIT.\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CompileResult:
|
|
49
|
+
"""Summary of a `compile_project` run.
|
|
50
|
+
|
|
51
|
+
`source_map` is the in-memory form of what was written to
|
|
52
|
+
`source_map_path`; downstream stages (notably the unittest runner)
|
|
53
|
+
re-use it without round-tripping through JSON.
|
|
54
|
+
|
|
55
|
+
`warnings` collects situations that likely need user action (e.g.
|
|
56
|
+
a missing `model-paths` entry in dbt_project.yml). `notices` are
|
|
57
|
+
informational reports of automatic behaviour that worked as
|
|
58
|
+
designed (e.g. an upstream row that was auto-injected). CLI shows
|
|
59
|
+
warnings by default and notices only under `--verbose`.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
project_root: Path
|
|
63
|
+
config: SlimTestConfig
|
|
64
|
+
output_dir: Path
|
|
65
|
+
generated_files: list[Path]
|
|
66
|
+
source_map_path: Path
|
|
67
|
+
source_map: dict[str, dict[str, Any]]
|
|
68
|
+
test_names: list[str]
|
|
69
|
+
warnings: list[str]
|
|
70
|
+
notices: list[str]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def test_count(self) -> int:
|
|
74
|
+
return len(self.test_names)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DuplicateTestNameError(SlimTestError):
|
|
78
|
+
"""Two unit tests resolved to the same `slimtest__<model>__<name>`."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, name: str, first: Path, second: Path) -> None:
|
|
81
|
+
super().__init__(
|
|
82
|
+
f"duplicate generated test name {name!r}: defined in {first} and {second}"
|
|
83
|
+
)
|
|
84
|
+
self.name = name
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compile_project(
|
|
88
|
+
project_root: Path,
|
|
89
|
+
*,
|
|
90
|
+
manifest: Manifest | None = None,
|
|
91
|
+
select: str | None = None,
|
|
92
|
+
) -> CompileResult:
|
|
93
|
+
"""Run the full compile flow against `project_root`.
|
|
94
|
+
|
|
95
|
+
If `manifest` is None, we try to load `target/manifest.json`; if
|
|
96
|
+
that file is also absent, upstreams are emitted as bare `ref()`
|
|
97
|
+
calls. `select` accepts a dbt-style selector (see `selector.py`).
|
|
98
|
+
"""
|
|
99
|
+
project_root = project_root.resolve()
|
|
100
|
+
config = load_config(project_root)
|
|
101
|
+
registry = FactoryRegistry.load(project_root / config.factories_path)
|
|
102
|
+
parsed_tests = filter_tests(find_slimtest_tests(project_root), select)
|
|
103
|
+
|
|
104
|
+
effective_manifest = manifest or try_load_manifest(project_root)
|
|
105
|
+
warnings: list[str] = []
|
|
106
|
+
notices: list[str] = []
|
|
107
|
+
|
|
108
|
+
expanded_by_model: dict[str, list[ExpandedUnitTest]] = {}
|
|
109
|
+
seen: dict[str, Path] = {}
|
|
110
|
+
|
|
111
|
+
for parsed in parsed_tests:
|
|
112
|
+
expected_upstreams: list[str] = []
|
|
113
|
+
if effective_manifest is not None:
|
|
114
|
+
expected_upstreams = effective_manifest.get_upstreams(parsed.model_name)
|
|
115
|
+
for resolved_spec in _resolve_spec(parsed):
|
|
116
|
+
expanded = expand_unit_test(
|
|
117
|
+
resolved_spec,
|
|
118
|
+
model=parsed.model_name,
|
|
119
|
+
registry=registry,
|
|
120
|
+
source_file=parsed.source_path,
|
|
121
|
+
source_line=parsed.source_line,
|
|
122
|
+
expected_upstreams=expected_upstreams,
|
|
123
|
+
auto_fill=config.auto_fill_upstreams,
|
|
124
|
+
)
|
|
125
|
+
if expanded.auto_filled_upstreams:
|
|
126
|
+
for upstream in expanded.auto_filled_upstreams:
|
|
127
|
+
notices.append(
|
|
128
|
+
f"auto-injected base row of factory {upstream!r} for "
|
|
129
|
+
f"upstream {upstream!r} in test "
|
|
130
|
+
f"{expanded.prefixed_name!r} (not present in `given`)"
|
|
131
|
+
)
|
|
132
|
+
if expanded.prefixed_name in seen:
|
|
133
|
+
raise DuplicateTestNameError(
|
|
134
|
+
expanded.prefixed_name,
|
|
135
|
+
first=seen[expanded.prefixed_name],
|
|
136
|
+
second=parsed.source_path,
|
|
137
|
+
)
|
|
138
|
+
seen[expanded.prefixed_name] = parsed.source_path
|
|
139
|
+
expanded_by_model.setdefault(parsed.model_name, []).append(expanded)
|
|
140
|
+
|
|
141
|
+
dbt_project = load_dbt_project(project_root)
|
|
142
|
+
if dbt_project is not None:
|
|
143
|
+
warning = check_generated_path_in_model_paths(
|
|
144
|
+
dbt_project, config.generated_yml_path
|
|
145
|
+
)
|
|
146
|
+
if warning is not None:
|
|
147
|
+
warnings.append(warning)
|
|
148
|
+
|
|
149
|
+
output_dir = project_root / config.generated_yml_path
|
|
150
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
generated_files: list[Path] = []
|
|
153
|
+
source_map: dict[str, dict[str, Any]] = {}
|
|
154
|
+
|
|
155
|
+
for model_name, tests in sorted(expanded_by_model.items()):
|
|
156
|
+
out_path = output_dir / f"{model_name}{GENERATED_FILE_SUFFIX}"
|
|
157
|
+
_write_generated_yml(out_path, tests, manifest=effective_manifest)
|
|
158
|
+
generated_files.append(out_path)
|
|
159
|
+
|
|
160
|
+
rel_out = _relative_or_self(out_path, project_root)
|
|
161
|
+
for test in tests:
|
|
162
|
+
source_map[test.prefixed_name] = {
|
|
163
|
+
"source_file": str(test.source_file),
|
|
164
|
+
"source_line": test.source_line,
|
|
165
|
+
"generated_file": str(rel_out),
|
|
166
|
+
"model": test.model,
|
|
167
|
+
"original_name": test.original_name,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
source_map_path = output_dir / SOURCE_MAP_FILENAME
|
|
171
|
+
with source_map_path.open("w", encoding="utf-8") as fh:
|
|
172
|
+
json.dump(source_map, fh, indent=2, sort_keys=True)
|
|
173
|
+
fh.write("\n")
|
|
174
|
+
|
|
175
|
+
test_names = sorted(source_map.keys())
|
|
176
|
+
|
|
177
|
+
return CompileResult(
|
|
178
|
+
project_root=project_root,
|
|
179
|
+
config=config,
|
|
180
|
+
output_dir=output_dir,
|
|
181
|
+
generated_files=generated_files,
|
|
182
|
+
source_map_path=source_map_path,
|
|
183
|
+
source_map=source_map,
|
|
184
|
+
test_names=test_names,
|
|
185
|
+
warnings=warnings,
|
|
186
|
+
notices=notices,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# -- yml writer --------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _write_generated_yml(
|
|
194
|
+
path: Path, tests: list[ExpandedUnitTest], *, manifest: Manifest | None
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Render the dbt-standard unit_tests yml for one model."""
|
|
197
|
+
yaml = YAML(typ="rt")
|
|
198
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
199
|
+
yaml.default_flow_style = False
|
|
200
|
+
|
|
201
|
+
document = _build_document(tests, manifest=manifest)
|
|
202
|
+
buf = StringIO()
|
|
203
|
+
yaml.dump(document, buf)
|
|
204
|
+
|
|
205
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
206
|
+
fh.write(GENERATED_HEADER)
|
|
207
|
+
fh.write(buf.getvalue())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_document(
|
|
211
|
+
tests: list[ExpandedUnitTest], *, manifest: Manifest | None
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""Build the plain Python dict that becomes the generated yml file."""
|
|
214
|
+
return {
|
|
215
|
+
"version": 2,
|
|
216
|
+
"unit_tests": [_test_to_dbt_dict(t, manifest=manifest) for t in tests],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _test_to_dbt_dict(
|
|
221
|
+
test: ExpandedUnitTest, *, manifest: Manifest | None
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
entry: dict[str, Any] = {
|
|
224
|
+
"name": test.prefixed_name,
|
|
225
|
+
"model": test.model,
|
|
226
|
+
}
|
|
227
|
+
if test.description:
|
|
228
|
+
entry["description"] = test.description
|
|
229
|
+
entry["given"] = [
|
|
230
|
+
{"input": _input_expression(upstream, manifest), "rows": rows}
|
|
231
|
+
for upstream, rows in test.given.items()
|
|
232
|
+
]
|
|
233
|
+
entry["expect"] = {"rows": test.expect}
|
|
234
|
+
return entry
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _input_expression(upstream: str, manifest: Manifest | None) -> str:
|
|
238
|
+
"""`ref(...)` or `source(...)` depending on what the manifest knows."""
|
|
239
|
+
if manifest is None:
|
|
240
|
+
return UpstreamRef(kind="ref", name=upstream).to_input_expression()
|
|
241
|
+
return manifest.resolve(upstream).to_input_expression()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resolve_spec(parsed: ParsedUnitTest) -> list[Any]:
|
|
245
|
+
"""Apply `parametrize` (1-to-N) and `scenario` (merge given) to one spec.
|
|
246
|
+
|
|
247
|
+
The output is a flat list of `UnitTestSpec`s with neither field set,
|
|
248
|
+
ready to feed `expand_unit_test`.
|
|
249
|
+
"""
|
|
250
|
+
expanded = expand_parametrize(parsed.spec)
|
|
251
|
+
return [apply_scenario(spec, parsed.scenarios) for spec in expanded]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _relative_or_self(path: Path, base: Path) -> Path:
|
|
255
|
+
try:
|
|
256
|
+
return path.relative_to(base)
|
|
257
|
+
except ValueError:
|
|
258
|
+
return path
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
__all__ = [
|
|
262
|
+
"CompileResult",
|
|
263
|
+
"DuplicateTestNameError",
|
|
264
|
+
"GENERATED_FILE_SUFFIX",
|
|
265
|
+
"GENERATED_HEADER",
|
|
266
|
+
"SOURCE_MAP_FILENAME",
|
|
267
|
+
"compile_project",
|
|
268
|
+
]
|
slimtest/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Loader for `slimtest.yml`.
|
|
2
|
+
|
|
3
|
+
The config file is optional. When absent, defaults from `SlimTestConfig`
|
|
4
|
+
are used.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ruamel.yaml import YAML
|
|
12
|
+
|
|
13
|
+
from .factory import SlimTestError
|
|
14
|
+
from .schema import SlimTestConfig
|
|
15
|
+
|
|
16
|
+
CONFIG_FILENAME = "slimtest.yml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidConfigError(SlimTestError):
|
|
20
|
+
"""`slimtest.yml` exists but is malformed or fails schema validation."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: Path, detail: str) -> None:
|
|
23
|
+
super().__init__(f"invalid {path}: {detail}")
|
|
24
|
+
self.path = path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config(project_root: Path) -> SlimTestConfig:
|
|
28
|
+
"""Load `<project_root>/slimtest.yml` or return defaults.
|
|
29
|
+
|
|
30
|
+
A missing file is not an error -- the MVP defaults are usable as-is.
|
|
31
|
+
A present but malformed file raises `InvalidConfigError`.
|
|
32
|
+
"""
|
|
33
|
+
config_path = project_root / CONFIG_FILENAME
|
|
34
|
+
if not config_path.exists():
|
|
35
|
+
return SlimTestConfig()
|
|
36
|
+
|
|
37
|
+
yaml = YAML(typ="safe")
|
|
38
|
+
try:
|
|
39
|
+
with config_path.open("r", encoding="utf-8") as fh:
|
|
40
|
+
raw = yaml.load(fh)
|
|
41
|
+
except Exception as exc: # noqa: BLE001
|
|
42
|
+
raise InvalidConfigError(config_path, str(exc)) from exc
|
|
43
|
+
|
|
44
|
+
if raw is None:
|
|
45
|
+
return SlimTestConfig()
|
|
46
|
+
if not isinstance(raw, dict):
|
|
47
|
+
raise InvalidConfigError(
|
|
48
|
+
config_path, f"expected a mapping at top level, got {type(raw).__name__}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return SlimTestConfig.model_validate(raw)
|
|
53
|
+
except Exception as exc: # noqa: BLE001 -- pydantic ValidationError
|
|
54
|
+
raise InvalidConfigError(config_path, str(exc)) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["CONFIG_FILENAME", "InvalidConfigError", "load_config"]
|
slimtest/dbt_project.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Read `dbt_project.yml` for the bits slimtest needs.
|
|
2
|
+
|
|
3
|
+
Right now that's just `model-paths`, so we know whether `dbt` will
|
|
4
|
+
actually pick up the generated yml we wrote to `target/slimtest/`. If
|
|
5
|
+
it won't, the CLI surfaces a warning that walks the user through fixing
|
|
6
|
+
it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ruamel.yaml import YAML
|
|
16
|
+
|
|
17
|
+
from .factory import SlimTestError
|
|
18
|
+
|
|
19
|
+
DBT_PROJECT_FILENAME = "dbt_project.yml"
|
|
20
|
+
DEFAULT_MODEL_PATHS: tuple[str, ...] = ("models",)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidDbtProjectError(SlimTestError):
|
|
24
|
+
"""`dbt_project.yml` exists but is malformed or has unexpected shape."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, path: Path, detail: str) -> None:
|
|
27
|
+
super().__init__(f"invalid {path}: {detail}")
|
|
28
|
+
self.path = path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class DbtProject:
|
|
33
|
+
"""Slim view over `dbt_project.yml`."""
|
|
34
|
+
|
|
35
|
+
project_root: Path
|
|
36
|
+
model_paths: tuple[str, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_dbt_project(project_root: Path) -> DbtProject | None:
|
|
40
|
+
"""Load `<project_root>/dbt_project.yml`, or return None if absent.
|
|
41
|
+
|
|
42
|
+
A missing file is not an error: `slimtest compile` works without
|
|
43
|
+
dbt being present at all (debugging use case).
|
|
44
|
+
"""
|
|
45
|
+
path = project_root / DBT_PROJECT_FILENAME
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
yaml = YAML(typ="safe")
|
|
50
|
+
try:
|
|
51
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
52
|
+
data: Any = yaml.load(fh)
|
|
53
|
+
except Exception as exc: # noqa: BLE001
|
|
54
|
+
raise InvalidDbtProjectError(path, str(exc)) from exc
|
|
55
|
+
|
|
56
|
+
if data is None:
|
|
57
|
+
return DbtProject(project_root=project_root, model_paths=DEFAULT_MODEL_PATHS)
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
raise InvalidDbtProjectError(
|
|
60
|
+
path, f"top level must be a mapping, got {type(data).__name__}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
raw_paths = data.get("model-paths", list(DEFAULT_MODEL_PATHS))
|
|
64
|
+
if not isinstance(raw_paths, list):
|
|
65
|
+
raise InvalidDbtProjectError(path, "`model-paths` must be a list")
|
|
66
|
+
if not all(isinstance(p, str) for p in raw_paths):
|
|
67
|
+
raise InvalidDbtProjectError(
|
|
68
|
+
path, "every entry in `model-paths` must be a string"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return DbtProject(
|
|
72
|
+
project_root=project_root,
|
|
73
|
+
model_paths=tuple(raw_paths),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_generated_path_in_model_paths(
|
|
78
|
+
dbt_project: DbtProject, generated_yml_path: str
|
|
79
|
+
) -> str | None:
|
|
80
|
+
"""Return a warning string if `generated_yml_path` isn't in model-paths.
|
|
81
|
+
|
|
82
|
+
The comparison normalises both sides via `Path()` so trailing
|
|
83
|
+
slashes, `./` prefixes, etc. don't cause false negatives.
|
|
84
|
+
"""
|
|
85
|
+
target = _normalise(generated_yml_path)
|
|
86
|
+
known = {_normalise(p) for p in dbt_project.model_paths}
|
|
87
|
+
if target in known:
|
|
88
|
+
return None
|
|
89
|
+
return (
|
|
90
|
+
f"`{generated_yml_path}` is not in `model-paths` in dbt_project.yml -- "
|
|
91
|
+
"dbt won't pick up the generated unit tests. Add it, e.g.:\n"
|
|
92
|
+
f" model-paths: [{', '.join(repr(p) for p in dbt_project.model_paths)}"
|
|
93
|
+
f", {generated_yml_path!r}]"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalise(path: str) -> str:
|
|
98
|
+
"""Canonical string form so equivalent paths compare equal."""
|
|
99
|
+
return str(Path(path))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"DBT_PROJECT_FILENAME",
|
|
104
|
+
"DEFAULT_MODEL_PATHS",
|
|
105
|
+
"DbtProject",
|
|
106
|
+
"InvalidDbtProjectError",
|
|
107
|
+
"check_generated_path_in_model_paths",
|
|
108
|
+
"load_dbt_project",
|
|
109
|
+
]
|