dbt-to-junit 0.1.3__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.
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dbt-to-junit
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Convert dbt's target/run_results.json into a JUnit XML report so Azure DevOps (ADO) can display dbt test results in the Tests tab.
|
|
5
|
+
Project-URL: Documentation, https://github.com/jorgecontrerasostos/dbt-junit-xml#readme
|
|
6
|
+
Project-URL: Issues, https://github.com/jorgecontrerasostos/dbt-junit-xml/issues
|
|
7
|
+
Project-URL: Source, https://github.com/jorgecontrerasostos/dbt-junit-xml
|
|
8
|
+
Author-email: Jorge Contreras <jorgecontrerasostos@gmail.com>
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Requires-Dist: junit-xml>=1.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: black>=24.8.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# dbt to JUnit XML
|
|
29
|
+
|
|
30
|
+
Convert dbt's `target/run_results.json` into a JUnit XML report so Azure DevOps (ADO) can display dbt test results in the Tests tab.
|
|
31
|
+
|
|
32
|
+
This tool is designed for CI: it reads the run results produced by `dbt build`, generates a single JUnit report, and writes it to a file that can be published by ADO.
|
|
33
|
+
|
|
34
|
+
## What it reads
|
|
35
|
+
|
|
36
|
+
- **Input**: dbt `run_results.json` (typically `target/run_results.json`)
|
|
37
|
+
- **Source of truth**: the `results` list inside that file
|
|
38
|
+
- **Filtering**: by default, only dbt tests are included (`unique_id` starts with `test.`)
|
|
39
|
+
|
|
40
|
+
## What it writes
|
|
41
|
+
|
|
42
|
+
- **Output**: a JUnit XML file (default: `dbt-junit.xml`)
|
|
43
|
+
- **Structure**: one `<testsuite>` containing one `<testcase>` per dbt test
|
|
44
|
+
|
|
45
|
+
## Install / run (local)
|
|
46
|
+
|
|
47
|
+
If you're using this repo with `uv`:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv sync
|
|
51
|
+
uv run dbt-junit-xml --input target/run_results.json --output dbt-junit.xml
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
You can also run it directly with Python:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m src.main --input target/run_results.json --output dbt-junit.xml
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## CLI options
|
|
61
|
+
|
|
62
|
+
- `--input`: path to `run_results.json` (default: `target/run_results.json`)
|
|
63
|
+
- `--output`: output XML path (default: `dbt-junit.xml`)
|
|
64
|
+
- `--log-level`: `DEBUG|INFO|WARNING|ERROR` (default: `INFO`)
|
|
65
|
+
- `--include-models`: include non-test nodes as testcases (default: off)
|
|
66
|
+
|
|
67
|
+
## Exit codes
|
|
68
|
+
|
|
69
|
+
- **0**: report generated and no failing dbt tests
|
|
70
|
+
- **1**: report generated and at least one dbt test failed/errored
|
|
71
|
+
- **2**: could not generate report (missing file, invalid JSON, unexpected format, etc.)
|
|
72
|
+
|
|
73
|
+
## Azure DevOps pipeline example
|
|
74
|
+
|
|
75
|
+
Run dbt (which produces `target/run_results.json`), generate the JUnit XML, then publish it:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- script: |
|
|
79
|
+
dbt build
|
|
80
|
+
dbt-junit-xml --input target/run_results.json --output dbt-junit.xml
|
|
81
|
+
displayName: "Run dbt and generate JUnit report"
|
|
82
|
+
|
|
83
|
+
- task: PublishTestResults@2
|
|
84
|
+
displayName: "Publish dbt test results"
|
|
85
|
+
inputs:
|
|
86
|
+
testResultsFormat: "JUnit"
|
|
87
|
+
testResultsFiles: "dbt-junit.xml"
|
|
88
|
+
failTaskOnFailedTests: true
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Notes / tips
|
|
92
|
+
|
|
93
|
+
- If your pipeline working directory is not the dbt project root, pass an explicit `--input` path.
|
|
94
|
+
- If you only want dbt tests in ADO, do not pass `--include-models` (default behavior already filters to tests).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
src/__about__.py,sha256=oUBsQ9adNpfQDpe2S_hjfsQi5HKRYeNo1kJUJZbwV_k,68
|
|
2
|
+
src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
src/main.py,sha256=-DlPCukpLhIrKZ8ptZ2l12B-3y3St6YgPV1jM6sAUl8,9969
|
|
4
|
+
dbt_to_junit-0.1.3.dist-info/METADATA,sha256=KKLmUMrqU92CcfpFZF2LNKTQW4VrYBsDlZEl_gSyJQY,3500
|
|
5
|
+
dbt_to_junit-0.1.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
dbt_to_junit-0.1.3.dist-info/entry_points.txt,sha256=4gllZrQ06MnH2myqq-epC12fgbzKJsslqFrVxnNFauk,48
|
|
7
|
+
dbt_to_junit-0.1.3.dist-info/licenses/LICENSE,sha256=uGf-ZjM9ze31xgitBaZZiWHnGf88sO5bnl3P2RO3Bik,1072
|
|
8
|
+
dbt_to_junit-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jorge Contreras
|
|
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.
|
src/__about__.py
ADDED
src/__init__.py
ADDED
|
File without changes
|
src/main.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from junit_xml import TestCase, TestSuite, to_xml_report_string
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _configure_logging(log_level: str) -> None:
|
|
13
|
+
"""Configure logging for this command-line tool.
|
|
14
|
+
|
|
15
|
+
This function configures Python's standard library logging (the root logger)
|
|
16
|
+
with a simple, pipeline-friendly format so messages show up in the terminal
|
|
17
|
+
output of local runs and CI systems (e.g., Azure DevOps).
|
|
18
|
+
|
|
19
|
+
Notes:
|
|
20
|
+
- This uses `logging.basicConfig(...)`, which only has an effect the first
|
|
21
|
+
time it is called (unless the logging system is reset elsewhere).
|
|
22
|
+
- Any unknown `log_level` value falls back to `INFO`.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
log_level: Logging level name (case-insensitive), for example:
|
|
26
|
+
- "DEBUG" to see verbose troubleshooting output
|
|
27
|
+
- "INFO" for normal operation
|
|
28
|
+
- "WARNING" / "ERROR" for quieter runs
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
None.
|
|
32
|
+
"""
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=getattr(logging, log_level.upper(), logging.INFO),
|
|
35
|
+
format="%(levelname)s %(message)s",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_run_results(path: Path) -> dict[str, Any]:
|
|
40
|
+
"""Read and parse a dbt `run_results.json` file from disk.
|
|
41
|
+
|
|
42
|
+
dbt writes `run_results.json` to the `target/` directory when `--write-json`
|
|
43
|
+
is enabled (it is enabled by default for most dbt invocations). This tool
|
|
44
|
+
treats that file as the source of truth for building a JUnit XML report that
|
|
45
|
+
Azure DevOps can render in the "Tests" tab.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
path: Path to the `run_results.json` file. This can be an absolute path
|
|
49
|
+
or a path relative to the current working directory.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A dictionary representing the parsed JSON document.
|
|
53
|
+
|
|
54
|
+
The expected top-level shape (simplified) is:
|
|
55
|
+
- `metadata`: dict (optional but common)
|
|
56
|
+
- `results`: list of per-node result dictionaries (required for report)
|
|
57
|
+
- `args`: dict with invocation details such as `which` (optional)
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
FileNotFoundError: If `path` does not exist or cannot be opened.
|
|
61
|
+
ValueError: If the file exists but is not valid JSON.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
with path.open(encoding="utf-8") as f:
|
|
65
|
+
return json.load(f)
|
|
66
|
+
except FileNotFoundError as e:
|
|
67
|
+
raise FileNotFoundError(f"run_results.json not found: {path}") from e
|
|
68
|
+
except json.JSONDecodeError as e:
|
|
69
|
+
raise ValueError(f"Invalid JSON in {path}: {e}") from e
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _to_junit_xml(
|
|
73
|
+
run_results: dict[str, Any], *, include_models: bool
|
|
74
|
+
) -> tuple[str, int]:
|
|
75
|
+
"""Convert parsed dbt run results into a JUnit XML report.
|
|
76
|
+
|
|
77
|
+
This function transforms dbt's `run_results.json` structure into a single
|
|
78
|
+
JUnit `<testsuite>` containing `<testcase>` entries. The resulting XML can
|
|
79
|
+
be published to Azure DevOps using the `PublishTestResults@2` task with
|
|
80
|
+
`testResultsFormat: JUnit`.
|
|
81
|
+
|
|
82
|
+
What gets included:
|
|
83
|
+
- By default (`include_models=False`), only dbt tests are included.
|
|
84
|
+
These can be identified by `unique_id` starting with `"test."`.
|
|
85
|
+
- If `include_models=True`, non-test nodes (e.g., models) are also
|
|
86
|
+
included as testcases.
|
|
87
|
+
|
|
88
|
+
How dbt statuses are mapped:
|
|
89
|
+
- "pass": testcase has no failure/error child elements (success)
|
|
90
|
+
- "skipped": testcase gets a `<skipped>` element
|
|
91
|
+
- "fail": testcase gets a `<failure>` element
|
|
92
|
+
- "error": testcase gets an `<error>` element
|
|
93
|
+
- anything else: if it's a dbt test, it's treated as an `<error>`
|
|
94
|
+
|
|
95
|
+
Naming and grouping:
|
|
96
|
+
- `classname` is derived from the first two `unique_id` segments so ADO
|
|
97
|
+
groups results nicely (e.g., `test.jaffle_shop`).
|
|
98
|
+
- `name` is the remaining portion of `unique_id`.
|
|
99
|
+
- If `metadata.invocation_id` is present, it is included in the suite name.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
run_results: Parsed contents of dbt `run_results.json` (output of
|
|
103
|
+
`_read_run_results`).
|
|
104
|
+
include_models: Whether to include non-test nodes as JUnit testcases.
|
|
105
|
+
Keep this False if you only want dbt tests to appear in ADO.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A tuple of:
|
|
109
|
+
- xml_report: A UTF-8 JUnit XML string (includes XML declaration).
|
|
110
|
+
- failing_test_count: Number of dbt *tests* (not models) that failed or
|
|
111
|
+
errored. This is useful for deciding process exit code in CI.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: If:
|
|
115
|
+
- `args.which` is present and not `"build"` (this tool expects dbt
|
|
116
|
+
build artifacts), or
|
|
117
|
+
- the JSON does not contain a top-level `results` list.
|
|
118
|
+
"""
|
|
119
|
+
which = run_results.get("args", {}).get("which")
|
|
120
|
+
if which and which != "build":
|
|
121
|
+
raise ValueError(f"Expected a dbt build artifact, got: {which}")
|
|
122
|
+
|
|
123
|
+
results = run_results.get("results")
|
|
124
|
+
if not isinstance(results, list):
|
|
125
|
+
raise ValueError("run_results.json missing a top-level 'results' list")
|
|
126
|
+
|
|
127
|
+
test_cases: list[TestCase] = []
|
|
128
|
+
failing_tests = 0
|
|
129
|
+
|
|
130
|
+
for result in results:
|
|
131
|
+
if not isinstance(result, dict):
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
unique_id = str(result.get("unique_id", "unknown"))
|
|
135
|
+
status = str(result.get("status", "unknown"))
|
|
136
|
+
execution_time = result.get("execution_time")
|
|
137
|
+
elapsed_sec = (
|
|
138
|
+
float(execution_time) if isinstance(execution_time, (int, float)) else None
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
is_test = unique_id.startswith("test.")
|
|
142
|
+
if not include_models and not is_test:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
parts = unique_id.split(".")
|
|
146
|
+
classname = ".".join(parts[:2]) if len(parts) >= 2 else parts[0]
|
|
147
|
+
name = ".".join(parts[2:]) if len(parts) >= 3 else unique_id
|
|
148
|
+
|
|
149
|
+
test_case = TestCase(name=name, classname=classname, elapsed_sec=elapsed_sec)
|
|
150
|
+
|
|
151
|
+
message = result.get("message")
|
|
152
|
+
if message is None and isinstance(result.get("adapter_response"), dict):
|
|
153
|
+
message = result["adapter_response"].get("_message")
|
|
154
|
+
message_str = "" if message is None else str(message)
|
|
155
|
+
|
|
156
|
+
if status == "pass":
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
if status == "skipped":
|
|
160
|
+
test_case.add_skipped_info(message=message_str or "skipped")
|
|
161
|
+
|
|
162
|
+
if status == "fail":
|
|
163
|
+
test_case.add_failure_info(
|
|
164
|
+
message=message_str or "dbt test failed", output=unique_id
|
|
165
|
+
)
|
|
166
|
+
if is_test:
|
|
167
|
+
failing_tests += 1
|
|
168
|
+
|
|
169
|
+
if status == "error":
|
|
170
|
+
test_case.add_error_info(
|
|
171
|
+
message=message_str or "dbt error", output=unique_id
|
|
172
|
+
)
|
|
173
|
+
if is_test:
|
|
174
|
+
failing_tests += 1
|
|
175
|
+
|
|
176
|
+
if status not in ["pass", "skipped", "fail", "error"]:
|
|
177
|
+
if is_test:
|
|
178
|
+
test_case.add_error_info(
|
|
179
|
+
message=f"Unknown dbt status: {status}", output=unique_id
|
|
180
|
+
)
|
|
181
|
+
failing_tests += 1
|
|
182
|
+
|
|
183
|
+
test_cases.append(test_case)
|
|
184
|
+
|
|
185
|
+
suite_name = "dbt"
|
|
186
|
+
if "metadata" in run_results and isinstance(run_results["metadata"], dict):
|
|
187
|
+
invocation_id = run_results["metadata"].get("invocation_id")
|
|
188
|
+
if invocation_id:
|
|
189
|
+
suite_name = f"dbt ({invocation_id})"
|
|
190
|
+
|
|
191
|
+
suite = TestSuite(suite_name, test_cases=test_cases)
|
|
192
|
+
|
|
193
|
+
xml = to_xml_report_string([suite], encoding="utf-8")
|
|
194
|
+
return xml, failing_tests
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main(argv: list[str] | None = None) -> int:
|
|
198
|
+
"""Run the CLI to convert dbt `run_results.json` into JUnit XML.
|
|
199
|
+
|
|
200
|
+
This is the entrypoint used by the `dbt-junit-xml` console script declared
|
|
201
|
+
in `pyproject.toml`. It reads a dbt `run_results.json` file, converts dbt
|
|
202
|
+
test results into a JUnit report, and writes the report to disk.
|
|
203
|
+
|
|
204
|
+
Typical usage (local):
|
|
205
|
+
- `dbt-junit-xml --input target/run_results.json --output dbt-junit.xml`
|
|
206
|
+
|
|
207
|
+
Typical usage (Azure DevOps):
|
|
208
|
+
1) Run `dbt build` (which produces `target/run_results.json`).
|
|
209
|
+
2) Run this tool to produce an XML file in the working directory.
|
|
210
|
+
3) Publish with `PublishTestResults@2` and `testResultsFormat: JUnit`.
|
|
211
|
+
|
|
212
|
+
Exit codes are designed for CI:
|
|
213
|
+
- 0 means the report was generated and there were no failing dbt tests.
|
|
214
|
+
- 1 means the report was generated but at least one dbt test failed.
|
|
215
|
+
- 2 means the report could not be generated (bad input, missing file, etc.).
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
argv: Optional list of CLI arguments (primarily for tests). If omitted,
|
|
219
|
+
arguments are read from `sys.argv` by `argparse`.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The process exit code (0/1/2 as described above).
|
|
223
|
+
"""
|
|
224
|
+
parser = argparse.ArgumentParser(
|
|
225
|
+
description="Convert dbt run_results.json to JUnit XML."
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"--input",
|
|
229
|
+
default=str(Path("target") / "run_results.json"),
|
|
230
|
+
help="Path to dbt run_results.json (default: target/run_results.json)",
|
|
231
|
+
)
|
|
232
|
+
parser.add_argument(
|
|
233
|
+
"--output",
|
|
234
|
+
default="dbt-junit.xml",
|
|
235
|
+
help="Path to write JUnit XML (default: dbt-junit.xml)",
|
|
236
|
+
)
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
"--include-models",
|
|
239
|
+
action="store_true",
|
|
240
|
+
help="Include model results as testcases (default: only dbt tests).",
|
|
241
|
+
)
|
|
242
|
+
parser.add_argument(
|
|
243
|
+
"--log-level",
|
|
244
|
+
default="INFO",
|
|
245
|
+
help="Logging level (DEBUG, INFO, WARNING, ERROR). Default: INFO",
|
|
246
|
+
)
|
|
247
|
+
args = parser.parse_args(argv)
|
|
248
|
+
_configure_logging(args.log_level)
|
|
249
|
+
|
|
250
|
+
input_path = Path(args.input)
|
|
251
|
+
output_path = Path(args.output)
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
run_results = _read_run_results(input_path)
|
|
255
|
+
xml, failing_tests = _to_junit_xml(
|
|
256
|
+
run_results, include_models=args.include_models
|
|
257
|
+
)
|
|
258
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
output_path.write_text(xml, encoding="utf-8")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error(str(e))
|
|
262
|
+
return 2
|
|
263
|
+
|
|
264
|
+
logger.info("Wrote JUnit XML to %s", output_path)
|
|
265
|
+
return 1 if failing_tests > 0 else 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
raise SystemExit(main())
|