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 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"]
@@ -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
+ ]