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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dbt-junit-xml = src.main:main
@@ -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
@@ -0,0 +1,3 @@
1
+ """Version information for dbt-junit-xml."""
2
+
3
+ __version__ = "0.1.3"
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())