From 8826225b1f58d8ba1d544badd210e46185503635 Mon Sep 17 00:00:00 2001 From: Siarhei Nekhviadovich Date: Wed, 18 May 2022 14:23:26 +0200 Subject: [PATCH 01/13] Take correct test_name for both yml declared tests and tests from files --- src/dbt_junitxml/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dbt_junitxml/main.py b/src/dbt_junitxml/main.py index 2d602bd..362e3d6 100644 --- a/src/dbt_junitxml/main.py +++ b/src/dbt_junitxml/main.py @@ -45,7 +45,7 @@ def parse(run_result, output): for test in tests: test_case = TestCase( classname=test["unique_id"], - name=test["unique_id"].split(".")[-2], + name=test["unique_id"].split(".")[2], elapsed_sec=test["execution_time"], status=test["status"], ) From 1856bfa3d8d53931c467010480cf69673a809647 Mon Sep 17 00:00:00 2001 From: Aliaksandra Sidarenka <96114873+si-aliaksandra@users.noreply.github.com> Date: Fri, 23 Dec 2022 11:52:14 +0100 Subject: [PATCH 02/13] Add log, sql to the report portal, change total testsuites time (#1) * [AS] add log to the report, change total testsuites time * add sql * update sql text Co-authored-by: BUDAPEST\Aliaksandra_Sidarenk --- src/dbt_junitxml/__init__.py | 0 src/dbt_junitxml/dbt_junit_xml.py | 229 ++++++++++++++++++++++++++++++ src/dbt_junitxml/main.py | 66 +++++++-- 3 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 src/dbt_junitxml/__init__.py create mode 100644 src/dbt_junitxml/dbt_junit_xml.py 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..90f96fb --- /dev/null +++ b/src/dbt_junitxml/dbt_junit_xml.py @@ -0,0 +1,229 @@ +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, + ): + 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.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) + + # 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 362e3d6..d71387c 100644 --- a/src/dbt_junitxml/main.py +++ b/src/dbt_junitxml/main.py @@ -1,13 +1,21 @@ import click import json -from junit_xml import TestCase, TestSuite, to_xml_report_string +from junit_xml import to_xml_report_string +from dbt_junitxml.dbt_junit_xml import DBTTestSuite, DBTTestCase +from datetime import datetime +import os 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') + + @click.group() def cli(): pass @@ -18,14 +26,21 @@ def cli(): "run_result", type=click.Path(exists=True) ) +@click.argument( + "manifest", + type=click.Path(exists=True) +) @click.argument( "output", type=click.Path(exists=False) ) -def parse(run_result, output): +def parse(run_result, manifest, output): 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"] schema_version = run_result["metadata"]["dbt_schema_version"] @@ -34,34 +49,69 @@ def parse(run_result, output): 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}.") + raise InvalidRunResult( + f"run_result.json must be from the output of `dbt test`. Got dbt {rpc_method}.") 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_path = os.path.join(config['root_path'], 'target', 'compiled', config['package_name'], + config['original_file_path'], config['path']) + 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 + try: + with open(sql_path, 'r') as sql: + sql_text = sql.readlines() + sql_text.insert(0, sql_log_format) + tests_manifest[test_name]['sql'] = str.join('', sql_text) + except FileNotFoundError as e: + sql_text = config['compiled_sql'] if 'compiled_sql' in config.keys() else config[ + 'raw_sql'] + sql_text = [sql_log_format, sql_text] + tests_manifest[test_name]['sql'] = str.join('', sql_text) + 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], elapsed_sec=test["execution_time"], status=test["status"], + timestamp=test_timestamp, + stdout=test_sql ) 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(f"Tests", + test_cases=test_cases, + time=total_elapsed_time, + timestamp=test_suite_timestamp) xml_report = to_xml_report_string([test_suite]) From 05404684466332aa1e45d341b7557ec62a74571f Mon Sep 17 00:00:00 2001 From: Aliaksandra Sidarenka <96114873+si-aliaksandra@users.noreply.github.com> Date: Tue, 27 Dec 2022 09:51:37 +0100 Subject: [PATCH 03/13] fix NotAdirectory issue (#2) * [AS] add log to the report, change total testsuites time * add sql * update sql text * [AS] remove sql file reading * [AS] add log to the report, change total testsuites time * add sql * [AS] remove sql file reading * sync with upstream Co-authored-by: BUDAPEST\Aliaksandra_Sidarenk --- src/dbt_junitxml/main.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/dbt_junitxml/main.py b/src/dbt_junitxml/main.py index d71387c..2cfe96c 100644 --- a/src/dbt_junitxml/main.py +++ b/src/dbt_junitxml/main.py @@ -64,22 +64,14 @@ def parse(run_result, manifest, output): if config['resource_type'] == 'test': test_name = key.split('.')[2] tests_manifest[test_name] = config - sql_path = os.path.join(config['root_path'], 'target', 'compiled', config['package_name'], - config['original_file_path'], config['path']) 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 - try: - with open(sql_path, 'r') as sql: - sql_text = sql.readlines() - sql_text.insert(0, sql_log_format) - tests_manifest[test_name]['sql'] = str.join('', sql_text) - except FileNotFoundError as e: - sql_text = config['compiled_sql'] if 'compiled_sql' in config.keys() else config[ - 'raw_sql'] - sql_text = [sql_log_format, sql_text] - tests_manifest[test_name]['sql'] = str.join('', sql_text) + sql_text = config['compiled_sql'] if 'compiled_sql' in config.keys() else config[ + 'raw_sql'] + sql_text = [sql_log_format, sql_text] + tests_manifest[test_name]['sql'] = str.join('', sql_text) test_cases = [] From d3213d22bec8a4d7cdbc423e8f1c6360aa722687 Mon Sep 17 00:00:00 2001 From: Sergey Nekhviadovich Date: Wed, 7 Jun 2023 18:14:19 +0200 Subject: [PATCH 04/13] Fix test results parsing for DBT 1.5 (#3) --- src/dbt_junitxml/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dbt_junitxml/main.py b/src/dbt_junitxml/main.py index 2cfe96c..40dc564 100644 --- a/src/dbt_junitxml/main.py +++ b/src/dbt_junitxml/main.py @@ -42,15 +42,15 @@ def parse(run_result, manifest, output): manifest = json.load(m)['nodes'] try: - rpc_method = run_result["args"]["rpc_method"] + executed_command = run_result["args"]["which"] 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": + if not executed_command == "test": raise InvalidRunResult( - f"run_result.json must be from the output of `dbt test`. Got dbt {rpc_method}.") + f"run_result.json must be from the output of `dbt test`. Got dbt {executed_command}.") except KeyError as e: raise InvalidRunResult(e) From 84760162421071bbef27a8cfcb0cef7793b7474e Mon Sep 17 00:00:00 2001 From: Siarhei Nekhviadovich Date: Wed, 7 Jun 2023 18:26:23 +0200 Subject: [PATCH 05/13] Fix sql handling for DBT 1.3+ and run_results backward compatibility for DBT prior 1.3 --- src/dbt_junitxml/main.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/dbt_junitxml/main.py b/src/dbt_junitxml/main.py index 40dc564..804bd18 100644 --- a/src/dbt_junitxml/main.py +++ b/src/dbt_junitxml/main.py @@ -42,7 +42,7 @@ def parse(run_result, manifest, output): manifest = json.load(m)['nodes'] try: - executed_command = run_result["args"]["which"] + 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": @@ -68,8 +68,14 @@ def parse(run_result, manifest, output): 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 - sql_text = config['compiled_sql'] if 'compiled_sql' in config.keys() else config[ - 'raw_sql'] + 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) From 2bfaa37599d37075c53cac3da86ea65ac448e391 Mon Sep 17 00:00:00 2001 From: Siarhei Nekhviadovich Date: Thu, 8 Jun 2023 13:27:56 +0200 Subject: [PATCH 06/13] Update README of the fork and bump version to 0.2.1 --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 6 ++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 38b7660..e3d7537 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.1#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 @@ -19,6 +31,54 @@ to parse your run results and output a jUnit XML formatted report named `report. dbt-junitxml parse target/run_results.json report.xml ``` +## 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/epm-plxd/launch/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/'` +``` + ## 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/pyproject.toml b/pyproject.toml index d5274dc..d59125d 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.1" +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" From 048be1902306bf43374cef6499b9c7d61e40fbb9 Mon Sep 17 00:00:00 2001 From: Krystsina Dubina Date: Wed, 21 Feb 2024 16:04:43 +0300 Subject: [PATCH 07/13] Add v5 support --- src/dbt_junitxml/main.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/dbt_junitxml/main.py b/src/dbt_junitxml/main.py index 804bd18..91ddea5 100644 --- a/src/dbt_junitxml/main.py +++ b/src/dbt_junitxml/main.py @@ -45,8 +45,11 @@ def parse(run_result, manifest, output): 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 schema_version not in [ + "https://schemas.getdbt.com/dbt/run-results/v4.json", + "https://schemas.getdbt.com/dbt/run-results/v5.json", + ]: + raise InvalidRunResult("run_result.json other than v4 and v5 are not supported.") if not executed_command == "test": raise InvalidRunResult( From d37a2959886b92f571ee5374225a48927b539715 Mon Sep 17 00:00:00 2001 From: Egor Tananaiko Date: Tue, 27 Aug 2024 15:15:11 +0200 Subject: [PATCH 08/13] Add v6 support --- src/dbt_junitxml/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dbt_junitxml/main.py b/src/dbt_junitxml/main.py index 91ddea5..060a22c 100644 --- a/src/dbt_junitxml/main.py +++ b/src/dbt_junitxml/main.py @@ -48,8 +48,9 @@ def parse(run_result, manifest, output): 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 and v5 are not supported.") + raise InvalidRunResult("run_result.json other than (v4-v6) are not supported.") if not executed_command == "test": raise InvalidRunResult( From d0445c68fb9b07c8a9a821d085d2476cee0c2e6e Mon Sep 17 00:00:00 2001 From: Aliaksandra Sidarenka <96114873+si-aliaksandra@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:12:50 +0200 Subject: [PATCH 09/13] Integration with reportportal: add custom custom attributes to the tests in junit.xml + unit tests * Integration with reportportal: add test case attributes (#3) * [AS] add log to the report, change total testsuites time * add sql * update sql text * [AS] remove sql file reading * [AS] add log to the report, change total testsuites time * add sql * [AS] remove sql file reading * sync with upstream * Add properties to junit xml report (#1) * add custom properties * update property validator * update docstring * Create python-app.yml * Update pyproject.toml * Update actions (#2) * add custom properties * update property validator * add tests * add pytest action * update actions * update tests run * add coverage * add coverage * fix pipeline * fix pipeline * fix pipeline * fix tests * fix tests * add coverage config * update poetry.lock * update poetry.lock * update actions * update actions * update actions * update actions * update import * update readme * update readme * Update README --- .github/workflows/python-app.yml | 36 +++++ README.md | 57 +++++-- poetry.lock | 243 ++++++++++++++++++++++-------- pyproject.toml | 38 ++++- requirements.txt | 0 src/dbt_junitxml/dbt_junit_xml.py | 17 +++ src/dbt_junitxml/main.py | 223 +++++++++++++++++++++------ tests/__init__.py | 0 tests/dbt_junitxml/__init__.py | 0 tests/dbt_junitxml/main_test.py | 62 ++++++++ 10 files changed, 548 insertions(+), 128 deletions(-) create mode 100644 .github/workflows/python-app.yml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/dbt_junitxml/__init__.py create mode 100644 tests/dbt_junitxml/main_test.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..974cd25 --- /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 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 e3d7537..91ca99b 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ 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: +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 . @@ -17,10 +17,10 @@ On top of that here were added: Publishing as a regular pip module is considered ```shell -pip install "git+https://github.com/SOVALINUX/dbt-junitxml@0.2.1#egg=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). +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 @@ -28,16 +28,23 @@ 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: + +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 @@ -53,9 +60,9 @@ 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: +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/ @@ -64,21 +71,41 @@ 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 +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/epm-plxd/launch/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"` +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/ +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..f5cc88b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,109 +1,224 @@ +# 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.7" description = "Composable command line interface toolkit" -category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] [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.1" +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.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] [[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.3" +description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] -[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 = [ +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ {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"}, -] -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.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, ] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.8" +content-hash = "1da7e953cdeab7738a4950339efda5fdd1dfeab3b1e852f40f9f69c6b3b89884" diff --git a/pyproject.toml b/pyproject.toml index d59125d..b0973c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dbt-junitxml" -version = "0.2.1" +version = "0.2.2" description = "Utility to convert DBT test results into Junit XML format" authors = ["Charles Lariviere ", "Siarhei Nekhviadovich ", "Aliaksandra Sidarenka "] readme = "README.md" @@ -11,11 +11,19 @@ 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"] @@ -23,3 +31,29 @@ 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/dbt_junit_xml.py b/src/dbt_junitxml/dbt_junit_xml.py index 90f96fb..fec8f00 100644 --- a/src/dbt_junitxml/dbt_junit_xml.py +++ b/src/dbt_junitxml/dbt_junit_xml.py @@ -21,6 +21,7 @@ def __init__( log=None, url=None, allow_multiple_subelements=False, + properties=None, ): self.name = name self.assertions = assertions @@ -35,6 +36,7 @@ def __init__( self.url = url self.stdout = stdout self.stderr = stderr + self.properties = properties self.is_enabled = True self.errors = [] @@ -178,6 +180,20 @@ def build_xml_doc(self, encoding=None): 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"]: @@ -226,4 +242,5 @@ def build_xml_doc(self, encoding=None): 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 060a22c..f4d978e 100644 --- a/src/dbt_junitxml/main.py +++ b/src/dbt_junitxml/main.py @@ -1,10 +1,17 @@ -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 dbt_junitxml.dbt_junit_xml import DBTTestSuite, DBTTestCase -from datetime import datetime -import os + +from .dbt_junit_xml import DBTTestCase +from .dbt_junit_xml import DBTTestSuite class InvalidRunResult(Exception): @@ -12,8 +19,74 @@ class InvalidRunResult(Exception): 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') + 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() @@ -22,27 +95,51 @@ def cli(): @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.argument( - "manifest", - type=click.Path(exists=True) +@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, manifest, 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'] + manifest = json.load(m)["nodes"] try: - executed_command = run_result["args"]["which"] if 'which' in run_result["args"].keys() else 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 schema_version not in [ @@ -50,70 +147,102 @@ def parse(run_result, manifest, output): "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.") + 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`. Got dbt {executed_command}.") + 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"]) + 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] + 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'] + 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 = config["raw_sql"] sql_text = [sql_log_format, sql_text] - tests_manifest[test_name]['sql'] = str.join('', 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_name = test["unique_id"].split('.')[2] - test_timestamp = test['timing'][0]["started_at"] if ["status"] == 'pass' \ + 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_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], elapsed_sec=test["execution_time"], status=test["status"], timestamp=test_timestamp, - stdout=test_sql + 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"], output=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"], output=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"], output=test["message"]) + test_case.add_skipped_info( + message=test["message"], + output=test["message"], + ) test_cases.append(test_case) - test_suite = DBTTestSuite(f"Tests", - test_cases=test_cases, - time=total_elapsed_time, - timestamp=test_suite_timestamp) + 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 From 1fc089c763f263e90bf427e053ed9d5f8393529a Mon Sep 17 00:00:00 2001 From: Zhan Date: Wed, 8 Jan 2025 13:49:01 +0100 Subject: [PATCH 10/13] prevent use of poetry-core 2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b0973c4..ba5a5b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ 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] From 754eda203098de9f15383a07643e2d1f9a3b3b5a Mon Sep 17 00:00:00 2001 From: Zhan Date: Wed, 8 Jan 2025 15:25:45 +0100 Subject: [PATCH 11/13] fix ci --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 974cd25..86d9f41 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install poetry coverage pytest click + pip install 'poetry>=1.0.0,<2.0.0' 'coverage' 'pytest' 'click' pip install .[tests] poetry install From 6b1da4ac7443cb2e871f1a100c7f2a0a2f375d0d Mon Sep 17 00:00:00 2001 From: Zhan Date: Fri, 10 Jan 2025 09:45:16 +0100 Subject: [PATCH 12/13] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ba5a5b8..11ec5d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dbt-junitxml" -version = "0.2.2" +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" From 768e0905165af1104c91162706001d54475f31b8 Mon Sep 17 00:00:00 2001 From: Zhan Date: Fri, 10 Jan 2025 10:02:47 +0100 Subject: [PATCH 13/13] Update lock file --- poetry.lock | 62 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/poetry.lock b/poetry.lock index f5cc88b..26bc36c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "click" -version = "8.1.7" +version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, ] [package.dependencies] @@ -150,13 +150,13 @@ six = "*" [[package]] name = "packaging" -version = "24.1" +version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] @@ -176,13 +176,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "8.3.3" +version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, ] [package.dependencies] @@ -198,24 +198,54 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] name = "tomli" -version = "2.0.2" +version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, + {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]