diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
new file mode 100644
index 0000000..86d9f41
--- /dev/null
+++ b/.github/workflows/python-app.yml
@@ -0,0 +1,36 @@
+name: dbt-junitxml
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+env:
+ DEFAULT_PYTHON: "3.10"
+
+jobs:
+ test:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install 'poetry>=1.0.0,<2.0.0' 'coverage' 'pytest' 'click'
+ pip install .[tests]
+ poetry install
+
+ - name: Run tests
+ run: |
+ cd src
+ coverage run --source=. -m pytest ../tests
diff --git a/README.md b/README.md
index 38b7660..91ca99b 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,24 @@
Convert your dbt test results into jUnit XML format so that CI/CD platforms (such as Jenkins, CircleCI, etc.)
can better report on tests in their UI.
+## About this fork
+
+This is the fork repository based on https://github.com/chasleslr/dbt-junitxml/ version 0.1.5
+On top of that here were added:
+1. Support of DBT Core 1.3+ (originally it supported only up to 1.2). Versions 0.2.x Tested on DBT 1.5
+2. In case of test failures Junit XML contains additional information regarding Stored Results and original test SQL. Details can be found below.
+3. Test name in the resulted xml is more specific rather than in original version .
+4. Supported integration with https://reportportal.io/
+
## Installation
+Publishing as a regular pip module is considered
+
```shell
-pip install dbt-junitxml
+pip install "git+https://github.com/SOVALINUX/dbt-junitxml@0.2.2#egg=dbt-junitxml"
```
+We recommend you to stick to some specific version, since newer versions might contain changes that may impact your operations (not being backward incompatible at all, but rather change some visualizations you might be used to).
## Usage
@@ -16,9 +28,84 @@ When you run your dbt test suite, the output is saved under `target/run_results.
to parse your run results and output a jUnit XML formatted report named `report.xml`.
```shell
-dbt-junitxml parse target/run_results.json report.xml
+dbt-junitxml parse --manifest target/manifest.json --run_result target/run_results.json --output report.xml
+```
+
+By default, --manifest is `target/manifest.json`, --run_result is `target/run_results.json` and --output is `report.xml`, so in case your input isn't different from these values, you could run:
+
+```shell
+dbt-junitxml parse
+```
+
+
+## Features description
+
+### Rich XML output in case of test failure
+
+In order to help you handle test failures right where you see it we're adding supporting information into Junit XML in case of test failure
+It's even more than you see in the DBT CLI console output!
+For example:
+
+```
+Got 19 results, configured to fail if != 0
+2023-06-08 10:47:02
+------------------------------------------------------------------------------------------------
+select * from db_dbt_test__audit.not_null_table_reporter_employee_id
+------------------------------------------------------------------------------------------------
+
+select *
+from (select * from "datacatalog"."db"."table" where NOT regexp_like(reporter_email_address, 'auto_.*?@company.com') AND reporter_email_address NOT IN ('exclude@company.com') AND reporter_email_address IS NOT NULL) dbt_subquery
+where reporter_employee_id is null
+```
+
+### Saving test SQL files for further analysis
+
+Sometimes it's handy to see the exact SQL that was executed and tested by DBT without repeating compilation steps.
+To achieve it we suggest you to save compiled tests SQL during your test run.
+Below you can find a reference script:
+```shell
+dbt test --store-failures
+mkdir -p target/compiled_all_sql && find target/compiled/ -name *.sql -print0 | xargs -0 cp -t target/compiled_all_sql/
+zip -r -q compiled_all_sql.zip target/compiled_all_sql
+```
+
+### Integration with Report Portal
+
+https://reportportal.io/ helps you to manage your test launches. Here at EPAM we're using this tool to manage over 4,000 DBT tests
+
+In order to upload your test run to reportportal you can use the following script:
+```shell
+dbt-junitxml parse target/run_results.json target/manifest.json dbt_test_report.xml
+zip dbt_test_report.zip dbt_test_report.xml
+REPORT_PORTAL_TOKEN=`Your token for Report Portal`
+RESPONSE=`curl -X POST "https://reportportal.io/api/v1/plugin/{project_name}/JUnit/import" -H "accept: */*" -H "Content-Type: multipart/form-data" -H "Authorization: bearer ${REPORT_PORTAL_TOKEN}" -F "file=@dbt_test_report.zip;type=application/x-zip-compressed"`
+LAUNCH_ID=`echo "${RESPONSE}" | sed 's/.*Launch with id = \(.*\) is successfully imported.*/\1/'`
+```
+
+### Test Case Attribute displayed in Report Portal
+
+Since 0.2.2 version you will be able to put attributes within an junit xml report. It can be beneficial for large dbt projects where we aim to categorize or group data quality tests based on the file structure. For this you'll need additionally provide --custom_properties:
+
+```shell
+dbt-junitxml parse --manifest target/manifest.json --run_result target/run_results.json --output report.xml --custom_properties Area=path_levels[2] --custom_properties Source=path_levels[1]
```
+where `path_levels` is a reserved variable, pointing to the directory that models stored, and index is a level of each subdirectory starting from the root of dbt project.
+
+
+Each test case will be enriched with properties, example:
+```xml
+
+
+
+
+```
+
+
## Limitations
Currently, only v4 of the [Run Results](https://docs.getdbt.com/reference/artifacts/run-results-json) specifications is supported.
+
+## Contribution
+
+Development of this fork was partially sponsored by EPAM Systems Inc. https://www.epam.com/
diff --git a/poetry.lock b/poetry.lock
index 61bcca4..26bc36c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,109 +1,254 @@
+# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+
[[package]]
name = "click"
-version = "8.1.2"
+version = "8.1.8"
description = "Composable command line interface toolkit"
-category = "main"
optional = false
python-versions = ">=3.7"
+files = [
+ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"},
+ {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"},
+]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "colorama"
-version = "0.4.4"
+version = "0.4.6"
description = "Cross-platform colored terminal text."
-category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
[[package]]
-name = "importlib-metadata"
-version = "4.11.3"
-description = "Read metadata from Python packages"
-category = "main"
+name = "coverage"
+version = "7.6.1"
+description = "Code coverage measurement for Python"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+ {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"},
+ {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"},
+ {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"},
+ {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"},
+ {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"},
+ {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"},
+ {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"},
+ {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"},
+ {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"},
+ {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"},
+ {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"},
+ {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"},
+ {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"},
+ {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"},
+ {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"},
+ {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"},
+ {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"},
+ {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"},
+ {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"},
+ {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"},
+ {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"},
+ {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"},
+ {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"},
+ {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"},
+ {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"},
+ {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"},
+ {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"},
+ {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"},
+ {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"},
+ {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"},
+ {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"},
+ {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"},
+ {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"},
+ {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"},
+ {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"},
+ {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"},
+ {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"},
+ {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"},
+ {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"},
+ {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"},
+ {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"},
+ {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"},
+ {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"},
+ {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"},
+ {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"},
+ {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"},
+ {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"},
+ {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"},
+ {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"},
+ {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"},
+ {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"},
+ {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"},
+ {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"},
+ {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"},
+ {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"},
+ {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"},
+ {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"},
+ {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"},
+ {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"},
+ {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"},
+ {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"},
+ {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"},
+ {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"},
+ {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"},
+ {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"},
+ {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"},
+ {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"},
+ {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"},
+ {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"},
+ {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"},
+ {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"},
+ {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"},
+]
-[package.dependencies]
-typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
-zipp = ">=0.5"
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
+ {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
+]
[package.extras]
-docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
-perf = ["ipython"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
[[package]]
name = "junit-xml"
version = "1.9"
description = "Creates JUnit XML test result documents that can be read by tools such as Jenkins"
-category = "main"
optional = false
python-versions = "*"
+files = [
+ {file = "junit-xml-1.9.tar.gz", hash = "sha256:de16a051990d4e25a3982b2dd9e89d671067548718866416faec14d9de56db9f"},
+ {file = "junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732"},
+]
[package.dependencies]
six = "*"
[[package]]
-name = "six"
-version = "1.16.0"
-description = "Python 2 and 3 compatibility utilities"
-category = "main"
+name = "packaging"
+version = "24.2"
+description = "Core utilities for Python packages"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+python-versions = ">=3.8"
+files = [
+ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
+ {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
+]
[[package]]
-name = "typing-extensions"
-version = "4.1.1"
-description = "Backported and Experimental Type Hints for Python 3.6+"
-category = "main"
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+ {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
[[package]]
-name = "zipp"
-version = "3.8.0"
-description = "Backport of pathlib-compatible object wrapper for zip files"
-category = "main"
+name = "pytest"
+version = "8.3.4"
+description = "pytest: simple powerful testing with Python"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
+ {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
+]
-[package.extras]
-docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.5,<2"
+tomli = {version = ">=1", markers = "python_version < \"3.11\""}
-[metadata]
-lock-version = "1.1"
-python-versions = ">=3.7"
-content-hash = "92c205fb8a27c6aa6f39dddaeab940624c4ee6488a8bf284e71ce1689d5b9915"
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
-[metadata.files]
-click = [
- {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"},
- {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"},
-]
-colorama = [
- {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
- {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
-]
-importlib-metadata = [
- {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"},
- {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"},
-]
-junit-xml = [
- {file = "junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732"},
-]
-six = [
- {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
- {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
-]
-typing-extensions = [
- {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"},
- {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
-zipp = [
- {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"},
- {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"},
+
+[[package]]
+name = "tomli"
+version = "2.2.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
+ {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
+ {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
+ {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
+ {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
+ {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
+ {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
+ {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
+ {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
+ {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
+ {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
+ {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
+ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
+ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
]
+
+[metadata]
+lock-version = "2.0"
+python-versions = ">=3.8"
+content-hash = "1da7e953cdeab7738a4950339efda5fdd1dfeab3b1e852f40f9f69c6b3b89884"
diff --git a/pyproject.toml b/pyproject.toml
index d5274dc..11ec5d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,8 @@
[tool.poetry]
name = "dbt-junitxml"
-version = "0.0.0"
-description = ""
-authors = ["Charles Lariviere "]
+version = "0.2.3"
+description = "Utility to convert DBT test results into Junit XML format"
+authors = ["Charles Lariviere ", "Siarhei Nekhviadovich ", "Aliaksandra Sidarenka "]
readme = "README.md"
license = "MIT"
repository = "https://github.com/chasleslr/dbt-junitxml"
@@ -11,15 +11,49 @@ packages = [
]
[tool.poetry.dependencies]
-python = ">=3.7"
+python = ">=3.8"
junit-xml = ">=1.9"
click = ">=8.1"
[tool.poetry.dev-dependencies]
+pytest = ">=8.0"
+coverage = ">=7.0"
+click = ">=8.1"
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^8.3.3"
+coverage = "^7.6.1"
+click = ">=8.1"
[build-system]
-requires = ["poetry-core>=1.0.0"]
+requires = ["poetry-core>=1.0.0,<2.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
dbt-junitxml = 'dbt_junitxml.main:cli'
+
+[tool.pytest.ini_options]
+minversion = "7.0"
+addopts = "--verbose"
+testpaths = [
+ "tests",
+]
+
+
+[project.optional-dependencies]
+tests = [
+ "pytest~=8.2.2",
+ "pytest-cov==5.0.0",
+ "pytest-mock==3.14.0",
+ "junit-xml>=1.9",
+ "click>=8.1",
+ "coverage",
+ "poetry"
+]
+
+[tool.coverage.run]
+command_line = "-m pytest"
+
+[tool.coverage.report]
+include = ["src/dbt_junitxml/*.py"]
+show_missing = true
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..e69de29
diff --git a/src/dbt_junitxml/__init__.py b/src/dbt_junitxml/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/dbt_junitxml/dbt_junit_xml.py b/src/dbt_junitxml/dbt_junit_xml.py
new file mode 100644
index 0000000..fec8f00
--- /dev/null
+++ b/src/dbt_junitxml/dbt_junit_xml.py
@@ -0,0 +1,246 @@
+from junit_xml import TestSuite, TestCase, decode
+import xml.etree.ElementTree as ET
+
+
+class DBTTestCase(TestCase):
+ """A JUnit test case with a result and possibly some stdout or stderr"""
+
+ def __init__(
+ self,
+ name,
+ classname=None,
+ elapsed_sec=None,
+ stdout=None,
+ stderr=None,
+ assertions=None,
+ timestamp=None,
+ status=None,
+ category=None,
+ file=None,
+ line=None,
+ log=None,
+ url=None,
+ allow_multiple_subelements=False,
+ properties=None,
+ ):
+ self.name = name
+ self.assertions = assertions
+ self.elapsed_sec = elapsed_sec
+ self.timestamp = timestamp
+ self.classname = classname
+ self.status = status
+ self.category = category
+ self.file = file
+ self.line = line
+ self.log = log
+ self.url = url
+ self.stdout = stdout
+ self.stderr = stderr
+ self.properties = properties
+
+ self.is_enabled = True
+ self.errors = []
+ self.failures = []
+ self.skipped = []
+ self.allow_multiple_subalements = allow_multiple_subelements
+
+
+class DBTTestSuite(TestSuite):
+ def __init__(self,
+ name,
+ test_cases=None,
+ hostname=None,
+ id=None,
+ package=None,
+ timestamp=None,
+ properties=None,
+ file=None,
+ log=None,
+ url=None,
+ stdout=None,
+ stderr=None,
+ time=None):
+ super(DBTTestSuite, self).__init__(name,
+ test_cases=None,
+ hostname=None,
+ id=None,
+ package=None,
+ timestamp=None,
+ properties=None,
+ file=None,
+ log=None,
+ url=None,
+ stdout=None,
+ stderr=None)
+ self.name = name
+ if not test_cases:
+ test_cases = []
+ try:
+ iter(test_cases)
+ except TypeError:
+ raise TypeError("test_cases must be a list of test cases")
+ self.test_cases = test_cases
+ self.timestamp = timestamp
+ self.hostname = hostname
+ self.id = id
+ self.package = package
+ self.file = file
+ self.log = log
+ self.url = url
+ self.stdout = stdout
+ self.stderr = stderr
+ self.properties = properties
+ self.time = time
+
+ def build_xml_doc(self, encoding=None):
+ super(DBTTestSuite, self).build_xml_doc(encoding=None)
+ """
+ Builds the XML document for the JUnit test suite.
+ Produces clean unicode strings and decodes non-unicode with the help of encoding.
+ @param encoding: Used to decode encoded strings.
+ @return: XML document with unicode string elements
+ """
+
+ # build the test suite element
+ test_suite_attributes = dict()
+ if any(c.assertions for c in self.test_cases):
+ test_suite_attributes["assertions"] = str(
+ sum([int(c.assertions) for c in self.test_cases if c.assertions]))
+ test_suite_attributes["disabled"] = str(
+ len([c for c in self.test_cases if not c.is_enabled]))
+ test_suite_attributes["errors"] = str(len([c for c in self.test_cases if c.is_error()]))
+ test_suite_attributes["failures"] = str(len([c for c in self.test_cases if c.is_failure()]))
+ test_suite_attributes["name"] = decode(self.name, encoding)
+ test_suite_attributes["skipped"] = str(len([c for c in self.test_cases if c.is_skipped()]))
+ test_suite_attributes["tests"] = str(len(self.test_cases))
+ test_suite_attributes["time"] = str(
+ sum(c.elapsed_sec for c in self.test_cases if c.elapsed_sec))
+
+ if self.hostname:
+ test_suite_attributes["hostname"] = decode(self.hostname, encoding)
+ if self.id:
+ test_suite_attributes["id"] = decode(self.id, encoding)
+ if self.package:
+ test_suite_attributes["package"] = decode(self.package, encoding)
+ if self.timestamp:
+ test_suite_attributes["timestamp"] = decode(self.timestamp, encoding)
+ if self.file:
+ test_suite_attributes["file"] = decode(self.file, encoding)
+ if self.log:
+ test_suite_attributes["log"] = decode(self.log, encoding)
+ if self.url:
+ test_suite_attributes["url"] = decode(self.url, encoding)
+ if self.time:
+ test_suite_attributes["time"] = decode(self.time, encoding)
+
+ xml_element = ET.Element("testsuite", test_suite_attributes)
+
+ # add any properties
+ if self.properties:
+ props_element = ET.SubElement(xml_element, "properties")
+ for k, v in self.properties.items():
+ attrs = {"name": decode(k, encoding), "value": decode(v, encoding)}
+ ET.SubElement(props_element, "property", attrs)
+
+ # add test suite stdout
+ if self.stdout:
+ stdout_element = ET.SubElement(xml_element, "system-out")
+ stdout_element.text = decode(self.stdout, encoding)
+
+ # add test suite stderr
+ if self.stderr:
+ stderr_element = ET.SubElement(xml_element, "system-err")
+ stderr_element.text = decode(self.stderr, encoding)
+
+ # test cases
+ for case in self.test_cases:
+ test_case_attributes = dict()
+ test_case_attributes["name"] = decode(case.name, encoding)
+ if case.assertions:
+ # Number of assertions in the test case
+ test_case_attributes["assertions"] = "%d" % case.assertions
+ if case.elapsed_sec:
+ test_case_attributes["time"] = "%f" % case.elapsed_sec
+ if case.timestamp:
+ test_case_attributes["timestamp"] = decode(case.timestamp, encoding)
+ if case.classname:
+ test_case_attributes["classname"] = decode(case.classname, encoding)
+ if case.status:
+ test_case_attributes["status"] = decode(case.status, encoding)
+ if case.category:
+ test_case_attributes["class"] = decode(case.category, encoding)
+ if case.file:
+ test_case_attributes["file"] = decode(case.file, encoding)
+ if case.line:
+ test_case_attributes["line"] = decode(case.line, encoding)
+ if case.log:
+ test_case_attributes["log"] = decode(case.log, encoding)
+ if case.url:
+ test_case_attributes["url"] = decode(case.url, encoding)
+
+ test_case_element = ET.SubElement(xml_element, "testcase", test_case_attributes)
+
+ # test properties
+ if case.properties:
+ case_props_element = ET.Element("properties")
+
+ for k, v in case.properties.items():
+ if isinstance(v, list):
+ for value in v:
+ attrs = {"name": decode(k, encoding), "value": decode(value, encoding)}
+ ET.SubElement(case_props_element, "property", attrs)
+ else:
+ attrs = {"name": decode(k, encoding), "value": decode(v, encoding)}
+ ET.SubElement(case_props_element, "property", attrs)
+
+ test_case_element.append(case_props_element)
+ # failures
+ for failure in case.failures:
+ if failure["output"] or failure["message"]:
+ attrs = {"type": "failure"}
+ if failure["message"]:
+ attrs["message"] = decode(failure["message"], encoding)
+ if failure["type"]:
+ attrs["type"] = decode(failure["type"], encoding)
+ failure_element = ET.Element("failure", attrs)
+ if failure["output"]:
+ failure_element.text = decode(failure["output"], encoding)
+ test_case_element.append(failure_element)
+
+ # errors
+ for error in case.errors:
+ if error["message"] or error["output"]:
+ attrs = {"type": "error"}
+ if error["message"]:
+ attrs["message"] = decode(error["message"], encoding)
+ if error["type"]:
+ attrs["type"] = decode(error["type"], encoding)
+ error_element = ET.Element("error", attrs)
+ if error["output"]:
+ error_element.text = decode(error["output"], encoding)
+ test_case_element.append(error_element)
+
+ # skipped
+ for skipped in case.skipped:
+ attrs = {"type": "skipped"}
+ if skipped["message"]:
+ attrs["message"] = decode(skipped["message"], encoding)
+ skipped_element = ET.Element("skipped", attrs)
+ if skipped["output"]:
+ skipped_element.text = decode(skipped["output"], encoding)
+ test_case_element.append(skipped_element)
+
+ # test stdout
+ if case.stdout:
+ stdout_element = ET.Element("system-out")
+ stdout_element.text = decode(case.stdout, encoding)
+ test_case_element.append(stdout_element)
+
+ # test stderr
+ if case.stderr:
+ stderr_element = ET.Element("system-err")
+ stderr_element.text = decode(case.stderr, encoding)
+ test_case_element.append(stderr_element)
+
+
+ return xml_element
diff --git a/src/dbt_junitxml/main.py b/src/dbt_junitxml/main.py
index 2d602bd..f4d978e 100644
--- a/src/dbt_junitxml/main.py
+++ b/src/dbt_junitxml/main.py
@@ -1,67 +1,248 @@
-import click
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
import json
+import logging
+import re
+from datetime import datetime
+from pathlib import Path
+
+import click
+from junit_xml import to_xml_report_string
-from junit_xml import TestCase, TestSuite, to_xml_report_string
+from .dbt_junit_xml import DBTTestCase
+from .dbt_junit_xml import DBTTestSuite
class InvalidRunResult(Exception):
pass
+def convert_timestamp_to_isoformat(timestamp: str) -> str:
+ return datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
+ "%Y-%m-%dT%H:%M:%S",
+ )
+
+
+def get_custom_properties(path: str, custom_properties: dict) -> dict:
+ """
+ :param path: string, path to model, ex. "models/source/area/some_model.yml
+ :param custom_properties: dictionary,
+ ex. {"Source":"path_levels[1]","Area":"path_levels[2]"}
+ :return: dictionary, ex. {"attribute": ["Source:source","Area:area"]}
+ """
+ path_levels = Path(path).parts
+ properties = {"attribute": []}
+ for key, value in custom_properties.items():
+ try:
+ if re.match(r"path_levels\[\d+]", value):
+ index = int(re.search(r"\d+", value).group())
+ attribute_value = f"{key}:{path_levels[index]}" # noqa
+ else:
+ attribute_value = f"{key}:{value}" # noqa
+ properties["attribute"].append(attribute_value)
+
+ except IndexError:
+ logging.error(
+ f"Index out of range for {key}: {value}, " f"path: {path}.",
+ )
+ continue
+
+ except Exception as e:
+ logging.error(f"Error updating custom property '{key}': {e}")
+ continue
+
+ return properties
+
+
+def validate_custom_properties(ctx, param, value):
+ if value is None:
+ return None
+
+ properties = {}
+ if not isinstance(value, (tuple, list)):
+ value = [value]
+
+ for prop_group in value:
+ items = prop_group.split(",")
+ for item in items:
+ item = item.strip()
+ if "=" not in item:
+ raise click.BadParameter(
+ f"Invalid custom property '{item}'. "
+ "Properties must be in the format key=value.",
+ )
+ key, val = item.split("=", 1)
+ key = key.strip()
+ val = val.strip()
+ if not key or not val:
+ raise click.BadParameter(
+ f"Invalid custom property '{item}'. "
+ "Both key and value must be non-empty.",
+ )
+ if key in properties:
+ raise click.BadParameter(
+ f"Duplicate custom property key '{key}'. "
+ "Each key must be unique.",
+ )
+ properties[key] = val
+ return properties
+
+
@click.group()
def cli():
pass
@cli.command()
-@click.argument(
- "run_result",
- type=click.Path(exists=True)
+@click.option(
+ "--manifest",
+ "-m",
+ type=click.Path(exists=True),
+ default=Path("target/manifest.json"),
+ help="DBT manifest file name",
+)
+@click.option(
+ "--run_result",
+ "-r",
+ type=click.Path(exists=True),
+ default=Path("target/run_results.json"),
+ help="DBT run results file name",
)
-@click.argument(
- "output",
- type=click.Path(exists=False)
+@click.option(
+ "--output",
+ "-o",
+ type=click.Path(exists=False),
+ default="report.xml",
+ help="Report output file name",
)
-def parse(run_result, output):
+@click.option(
+ "--custom_properties",
+ "-cp",
+ multiple=True,
+ type=str,
+ help="Add custom properties to the report, "
+ "e.g. --custom_properties key1=value1 --custom_properties key2=value2",
+ prompt_required=False,
+ callback=validate_custom_properties,
+ default=None,
+)
+def parse(run_result, manifest, output, custom_properties=None):
with open(run_result) as f:
run_result = json.load(f)
+ with open(manifest) as m:
+ manifest = json.load(m)["nodes"]
+
try:
- rpc_method = run_result["args"]["rpc_method"]
+ executed_command = (
+ run_result["args"]["which"]
+ if "which" in run_result["args"].keys()
+ else run_result["args"]["rpc_method"]
+ )
schema_version = run_result["metadata"]["dbt_schema_version"]
- if not schema_version == "https://schemas.getdbt.com/dbt/run-results/v4.json":
- raise InvalidRunResult("run_result.json other than v4 are not supported.")
-
- if not rpc_method == "test":
- raise InvalidRunResult(f"run_result.json must be from the output of `dbt test`. Got dbt {rpc_method}.")
+ if schema_version not in [
+ "https://schemas.getdbt.com/dbt/run-results/v4.json",
+ "https://schemas.getdbt.com/dbt/run-results/v5.json",
+ "https://schemas.getdbt.com/dbt/run-results/v6.json",
+ ]:
+ raise InvalidRunResult(
+ "run_result.json other than (v4-v6) are not supported.",
+ )
+
+ if not executed_command == "test":
+ raise InvalidRunResult(
+ f"run_result.json must be from the output of 'dbt test'. "
+ f"Got dbt {executed_command}.",
+ )
except KeyError as e:
raise InvalidRunResult(e)
tests = run_result["results"]
+ total_elapsed_time = run_result["elapsed_time"]
+ test_suite_timestamp = convert_timestamp_to_isoformat(
+ run_result["metadata"]["generated_at"],
+ )
+
+ tests_manifest = {}
+ for key, config in manifest.items():
+ if config["resource_type"] == "test":
+ test_name = key.split(".")[2]
+ tests_manifest[test_name] = config
+ sql_log = f"""select * from {tests_manifest[test_name]['schema']}.{
+ tests_manifest[test_name]['alias']
+ if tests_manifest[test_name]['alias']
+ else tests_manifest[test_name]['name']
+ }"""
+ sql_log_format = "\n" + "-" * 96 + "\n" + sql_log + "\n" + "-" * 96
+ if "compiled_sql" in config.keys():
+ sql_text = config["compiled_sql"]
+ elif "compiled_code" in config.keys():
+ sql_text = config["compiled_code"]
+ elif "raw_code" in config.keys():
+ sql_text = config["raw_code"]
+ else:
+ sql_text = config["raw_sql"]
+ sql_text = [sql_log_format, sql_text]
+ tests_manifest[test_name]["sql"] = str.join("", sql_text)
+ tests_manifest[test_name]["properties"] = get_custom_properties(
+ config["original_file_path"],
+ custom_properties,
+ )
test_cases = []
for test in tests:
- test_case = TestCase(
+ test_name = test["unique_id"].split(".")[2]
+ test_timestamp = (
+ test["timing"][0]["started_at"]
+ if ["status"] == "pass"
+ else test_suite_timestamp
+ )
+ test_sql = (
+ tests_manifest[test_name]["sql"]
+ if test_name in tests_manifest.keys()
+ else "N/A"
+ )
+ test_case = DBTTestCase(
classname=test["unique_id"],
- name=test["unique_id"].split(".")[-2],
+ name=test["unique_id"].split(".")[2],
elapsed_sec=test["execution_time"],
status=test["status"],
+ timestamp=test_timestamp,
+ stdout=test_sql,
+ properties=tests_manifest[test_name]["properties"]
+ if test_name in tests_manifest.keys()
+ else None,
)
if test["status"] == "fail":
- test_case.add_failure_info(message=test["message"])
+ test_case.add_failure_info(
+ message=test["message"],
+ output=test["message"],
+ )
if test["status"] == "error":
- test_case.add_error_info(message=test["message"])
+ test_case.add_error_info(
+ message=test["message"],
+ output=test["message"],
+ )
if test["status"] == "skipped":
- test_case.add_skipped_info(message=test["message"])
+ test_case.add_skipped_info(
+ message=test["message"],
+ output=test["message"],
+ )
test_cases.append(test_case)
- test_suite = TestSuite("Tests", test_cases=test_cases)
+ test_suite = DBTTestSuite(
+ "Tests",
+ test_cases=test_cases,
+ time=total_elapsed_time,
+ timestamp=test_suite_timestamp,
+ )
xml_report = to_xml_report_string([test_suite])
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/dbt_junitxml/__init__.py b/tests/dbt_junitxml/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/dbt_junitxml/main_test.py b/tests/dbt_junitxml/main_test.py
new file mode 100644
index 0000000..129cb25
--- /dev/null
+++ b/tests/dbt_junitxml/main_test.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import click.exceptions
+import pytest
+from dbt_junitxml.main import get_custom_properties
+from dbt_junitxml.main import validate_custom_properties
+
+
+@pytest.mark.parametrize(
+ "value, expected",
+ [
+ ("param1=1,param2=2", {"param1": "1", "param2": "2"}),
+ (("param1=1", "param2=2"), {"param1": "1", "param2": "2"}),
+ ],
+)
+def test_validate_custom_properties(value, expected):
+ assert validate_custom_properties(None, None, value) == expected
+
+
+def test_validate_custom_properies_none():
+ assert validate_custom_properties(None, None, None) is None
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "param1=1,param2=2,",
+ "param1:1",
+ ],
+)
+def test_validate_custom_properties_error(value):
+ with pytest.raises(click.exceptions.BadParameter):
+ validate_custom_properties(None, None, "param1=1,param2")
+
+
+@pytest.mark.parametrize(
+ "path, custom_properties, expected",
+ [
+ (
+ "models/source/area/some_model.yml",
+ {"Source": "path_levels[1]", "Area": "path_levels[2]"},
+ {"attribute": ["Source:source", "Area:area"]},
+ ),
+ (
+ "models/source/area/some_model.yml",
+ {"version": "1.2"},
+ {"attribute": ["version:1.2"]},
+ ),
+ (
+ "models/source/area/some_model.yml",
+ {
+ "Source": "path_levels[1]",
+ "Area": "path_levels[4]",
+ "version": "1.2",
+ },
+ {"attribute": ["Source:source", "version:1.2"]},
+ ),
+ ],
+)
+def test_get_custom_properties(path, custom_properties, expected):
+ assert get_custom_properties(path, custom_properties) == expected