From 61eb992b3c699d03eda9102395953d76d82f7a56 Mon Sep 17 00:00:00 2001 From: Korbinian Weber Date: Fri, 13 Jun 2025 23:11:15 +0200 Subject: [PATCH 01/29] mypy junitparser --- sphinxcontrib/test_reports/junitparser.py | 33 +++++++++++++---------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/sphinxcontrib/test_reports/junitparser.py b/sphinxcontrib/test_reports/junitparser.py index 7430f6a..7ac903a 100644 --- a/sphinxcontrib/test_reports/junitparser.py +++ b/sphinxcontrib/test_reports/junitparser.py @@ -4,20 +4,23 @@ import os +from typing import Optional, Any, cast from lxml import etree, objectify +from lxml.objectify import ObjectifiedElement class JUnitParser: - def __init__(self, junit_xml, junit_xsd=None): + def __init__(self, junit_xml: str, junit_xsd: Optional[str] = None) -> None: + self.junit_xml_path = junit_xml if junit_xsd is None: junit_xsd = os.path.join(os.path.dirname(__file__), "schemas", "JUnit.xsd") self.junit_xsd_path = junit_xsd - self.junit_schema_doc = None - self.xmlschema = None - self.valid_xml = None + self.junit_schema_doc: Optional[etree._ElementTree] = None + self.xmlschema: Optional[etree.XMLSchema] = None + self.valid_xml: Optional[bool] = None if not os.path.exists(self.junit_xml_path): raise JUnitFileMissing( @@ -29,14 +32,16 @@ def __init__(self, junit_xml, junit_xsd=None): self.junit_xml_object = objectify.fromstring(self.junit_xml_string) self.junit_xml_string = str(self.junit_xml_string) - def validate(self): + def validate(self) -> bool: self.junit_schema_doc = etree.parse(self.junit_xsd_path) self.xmlschema = etree.XMLSchema(self.junit_schema_doc) + assert self.xmlschema is not None self.valid_xml = self.xmlschema.validate(self.junit_xml_doc) return self.valid_xml - def parse(self): + def parse(self) -> list[dict[str, Any]]: + """ Creates a common python list of object, no matter what information are supported by the parsed xml file for test results junit(). @@ -44,7 +49,7 @@ def parse(self): :return: list of test suites as dictionaries """ - def parse_testcase(xml_object): + def parse_testcase(xml_object: ObjectifiedElement) -> dict[str, Any]: testcase = xml_object tc_dict = { @@ -79,13 +84,13 @@ def parse_testcase(xml_object): tc_dict["message"] = "" if hasattr(testcase, "system-out"): - tc_dict["system-out"] = testcase["system-out"].text + tc_dict["system-out"] = getattr(testcase, "system-out").text else: tc_dict["system-out"] = "" return tc_dict - def parse_testsuite(xml_object): + def parse_testsuite(xml_object: ObjectifiedElement) -> dict[str, Any]: testsuite = xml_object tests = int(testsuite.attrib.get("tests", -1)) @@ -116,12 +121,12 @@ def parse_testsuite(xml_object): if hasattr(testsuite, "testsuite"): for ts in testsuite.testsuite: # dict from inner parse - inner_testsuite = parse_testsuite(ts) + inner_testsuite = parse_testsuite(cast(ObjectifiedElement, ts)) ts_dict["testsuite_nested"].append(inner_testsuite) elif hasattr(testsuite, "testcase"): for tc in testsuite.testcase: - new_testcase = parse_testcase(tc) + new_testcase = parse_testcase(cast(ObjectifiedElement, tc)) ts_dict["testcases"].append(new_testcase) return ts_dict @@ -132,15 +137,15 @@ def parse_testsuite(xml_object): if self.junit_xml_object.tag == "testsuites": for testsuite_xml_object in self.junit_xml_object.testsuite: - complete_testsuite = parse_testsuite(testsuite_xml_object) + complete_testsuite = parse_testsuite(cast(ObjectifiedElement, testsuite_xml_object)) junit_dict.append(complete_testsuite) else: - complete_testsuite = parse_testsuite(self.junit_xml_object) + complete_testsuite = parse_testsuite(cast(ObjectifiedElement, self.junit_xml_object)) junit_dict.append(complete_testsuite) return junit_dict - def docutils_table(self): + def docutils_table(self) -> None: pass From 65dad785e5db317ffccfafe9d079fe7cd7713df6 Mon Sep 17 00:00:00 2001 From: Marco Heinemann Date: Mon, 16 Jun 2025 16:19:57 +0200 Subject: [PATCH 02/29] Fix fmt --- sphinxcontrib/test_reports/junitparser.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sphinxcontrib/test_reports/junitparser.py b/sphinxcontrib/test_reports/junitparser.py index 7ac903a..67b41dc 100644 --- a/sphinxcontrib/test_reports/junitparser.py +++ b/sphinxcontrib/test_reports/junitparser.py @@ -3,15 +3,14 @@ """ import os +from typing import Any, Optional, cast -from typing import Optional, Any, cast from lxml import etree, objectify from lxml.objectify import ObjectifiedElement class JUnitParser: def __init__(self, junit_xml: str, junit_xsd: Optional[str] = None) -> None: - self.junit_xml_path = junit_xml if junit_xsd is None: @@ -41,7 +40,6 @@ def validate(self) -> bool: return self.valid_xml def parse(self) -> list[dict[str, Any]]: - """ Creates a common python list of object, no matter what information are supported by the parsed xml file for test results junit(). @@ -137,10 +135,14 @@ def parse_testsuite(xml_object: ObjectifiedElement) -> dict[str, Any]: if self.junit_xml_object.tag == "testsuites": for testsuite_xml_object in self.junit_xml_object.testsuite: - complete_testsuite = parse_testsuite(cast(ObjectifiedElement, testsuite_xml_object)) + complete_testsuite = parse_testsuite( + cast(ObjectifiedElement, testsuite_xml_object) + ) junit_dict.append(complete_testsuite) else: - complete_testsuite = parse_testsuite(cast(ObjectifiedElement, self.junit_xml_object)) + complete_testsuite = parse_testsuite( + cast(ObjectifiedElement, self.junit_xml_object) + ) junit_dict.append(complete_testsuite) return junit_dict From bcaba18200816f4137ca76fead26fe413b9a1e2e Mon Sep 17 00:00:00 2001 From: Marco Heinemann Date: Mon, 16 Jun 2025 16:20:06 +0200 Subject: [PATCH 03/29] Remove junitparser from excludes --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9dbb431..96d09c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,6 @@ exclude = [ "^sphinxcontrib/test_reports/exceptions\\.py$", "^sphinxcontrib/test_reports/functions", "^sphinxcontrib/test_reports/jsonparser\\.py$", - "^sphinxcontrib/test_reports/junitparser\\.py$", "^sphinxcontrib/test_reports/test_reports\\.py$", ] # Disallow dynamic typing From 019810b3c02219497d5d0f6bab8b2ec565410b1f Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sun, 20 Jul 2025 19:23:32 +0200 Subject: [PATCH 04/29] Fix all mypy errors in jsonparser.py --- sphinxcontrib/test_reports/jsonparser.py | 113 +++++++++++++++-------- 1 file changed, 73 insertions(+), 40 deletions(-) diff --git a/sphinxcontrib/test_reports/jsonparser.py b/sphinxcontrib/test_reports/jsonparser.py index 19a5e55..cd39d48 100644 --- a/sphinxcontrib/test_reports/jsonparser.py +++ b/sphinxcontrib/test_reports/jsonparser.py @@ -9,11 +9,28 @@ import json import operator import os -from functools import reduce -from typing import Any, Dict, List - - -def dict_get(root, items, default=None): +from typing import ( + Dict, + List, + Union, + Sequence, + Optional, + Tuple, + TypedDict, + cast, +) + + +class MappingEntry(TypedDict): + testcase: Dict[str, Tuple[Sequence[Union[str, int]], object]] + testsuite: Dict[str, Tuple[Sequence[Union[str, int]], object]] + + +def dict_get( + root: Union[Dict[Union[str, int], object], List[object]], + items: Sequence[Union[str, int]], + default: Optional[object] = None +) -> object: """ Access a nested object in root by item sequence. @@ -23,31 +40,48 @@ def dict_get(root, items, default=None): """ try: - value = reduce(operator.getitem, items, root) + obj: Union[Dict[Union[str, int], object], List[object]] = root + for key in items: + next_obj = obj[key] # type: ignore[index] + obj = cast(Union[Dict[Union[str, int], object], List[object]], next_obj) + return obj except (KeyError, IndexError, TypeError): return default - return value + class JsonParser: - def __init__(self, json_path, *args, **kwargs): + json_path: str + json_data: List[Dict[str, object]] + json_mapping: MappingEntry + + def __init__(self, json_path: str, *args: object, **kwargs: Dict[str, object]) -> None: self.json_path = json_path if not os.path.exists(self.json_path): raise JsonFileMissing(f"The given file does not exist: {self.json_path}") - self.json_data = [] - with open(self.json_path) as jfile: - self.json_data = json.load(jfile) + with open(self.json_path, encoding="utf-8") as jfile: + data_raw = json.load(jfile) # type: ignore + data: List[Dict[str, object]] = cast(List[Dict[str, object]], data_raw) + + if not isinstance(data, list): + raise TypeError("Expected top-level JSON to be a list of dicts") - self.json_mapping = kwargs.get("json_mapping", {}) + self.json_data = data - def validate(self): + mapping_fallback: MappingEntry = {"testcase": {}, "testsuite": {}} + self.json_mapping = cast( + MappingEntry, + kwargs.get("json_mapping", mapping_fallback) + ) + + def validate(self) -> bool: # For JSON we validate nothing here. # But to be compatible with the API, we need to return True return True - def parse(self) -> List[Dict[str, Any]]: + def parse(self) -> List[Dict[str, object]]: """ Creates a common python list of object, no matter what information are supported by the parsed json file for test results junit(). @@ -55,44 +89,43 @@ def parse(self) -> List[Dict[str, Any]]: :return: list of test suites as dictionaries """ - def parse_testcase(json_dict) -> Dict[str, Any]: - tc_mapping = self.json_mapping.get("testcase") - tc_dict = { - k: dict_get(json_dict, v[0], v[1]) for k, v in tc_mapping.items() + def parse_testcase(json_dict: Dict[Union[str, int], object]) -> Dict[str, object]: + tc_mapping = self.json_mapping.get("testcase", {}) + return { + k: dict_get(json_dict, path, fallback) + for k, (path, fallback) in tc_mapping.items() } - return tc_dict - def parse_testsuite(json_dict) -> Dict[str, Any]: - ts_mapping = self.json_mapping.get("testsuite") - ts_dict = { - k: dict_get(json_dict, v[0], v[1]) - for k, v in ts_mapping.items() + def parse_testsuite(json_dict: Dict[Union[str, int], object]) -> Dict[str, object]: + ts_mapping = self.json_mapping.get("testsuite", {}) + ts_dict: Dict[str, object] = { + k: dict_get(json_dict, path, fallback) + for k, (path, fallback) in ts_mapping.items() if k != "testcases" } - ts_dict.update({"testcases": [], "testsuite_nested": []}) - testcases = dict_get( - json_dict, ts_mapping["testcases"][0], ts_mapping["testcases"][1] - ) - for tc in testcases: - new_testcase = parse_testcase(tc) - ts_dict["testcases"].append(new_testcase) + testcases: List[Dict[str, object]] = [] + ts_dict["testcases"] = testcases + ts_dict["testsuite_nested"] = [] + + testcase_entry = ts_mapping.get("testcases") + if testcase_entry: + testcases_raw = dict_get(json_dict, testcase_entry[0], testcase_entry[1]) + if isinstance(testcases_raw, list): + for item in testcases_raw: + if isinstance(item, dict): + tc = parse_testcase(item) + testcases.append(tc) return ts_dict # main flow starts here - result_data = [] - - for testsuite_data in self.json_data: - complete_testsuite = parse_testsuite(testsuite_data) - result_data.append(complete_testsuite) - - return result_data + return [parse_testsuite(cast(Dict[Union[str, int], object], ts)) for ts in self.json_data] - def docutils_table(self): + def docutils_table(self) -> None: pass -class JsonFileMissing(BaseException): +class JsonFileMissing(Exception): pass From 4427f0723f2b0e8d82164711fe17771204170290 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sun, 20 Jul 2025 19:25:55 +0200 Subject: [PATCH 05/29] Fix all mypy errors in junitparser.py --- sphinxcontrib/test_reports/junitparser.py | 136 +++++++++++----------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/sphinxcontrib/test_reports/junitparser.py b/sphinxcontrib/test_reports/junitparser.py index 67b41dc..f202053 100644 --- a/sphinxcontrib/test_reports/junitparser.py +++ b/sphinxcontrib/test_reports/junitparser.py @@ -1,45 +1,48 @@ """ JUnit XML parser """ - import os -from typing import Any, Optional, cast - +from typing import Optional, Dict, List, cast from lxml import etree, objectify -from lxml.objectify import ObjectifiedElement +from lxml.etree import _ElementTree, _Element class JUnitParser: + junit_xml_path: str + junit_xsd_path: str + junit_schema_doc: Optional[_ElementTree] + xmlschema: Optional[etree.XMLSchema] + valid_xml: Optional[bool] + junit_xml_doc: _ElementTree + junit_xml_string: str + junit_xml_object: _Element + def __init__(self, junit_xml: str, junit_xsd: Optional[str] = None) -> None: self.junit_xml_path = junit_xml - if junit_xsd is None: junit_xsd = os.path.join(os.path.dirname(__file__), "schemas", "JUnit.xsd") self.junit_xsd_path = junit_xsd - self.junit_schema_doc: Optional[etree._ElementTree] = None - self.xmlschema: Optional[etree.XMLSchema] = None - self.valid_xml: Optional[bool] = None - if not os.path.exists(self.junit_xml_path): - raise JUnitFileMissing( - f"The given file does not exist: {self.junit_xml_path}" - ) - self.junit_xml_doc = etree.parse(self.junit_xml_path) + raise JUnitFileMissing(f"The given file does not exist: {self.junit_xml_path}") - self.junit_xml_string = etree.tostring(self.junit_xml_doc) - self.junit_xml_object = objectify.fromstring(self.junit_xml_string) - self.junit_xml_string = str(self.junit_xml_string) + self.junit_schema_doc = None + self.xmlschema = None + self.valid_xml = None + + parsed: _ElementTree = etree.parse(self.junit_xml_path) + self.junit_xml_doc = parsed + self.junit_xml_string = etree.tostring(self.junit_xml_doc).decode() + raw_obj = objectify.fromstring(self.junit_xml_string) + self.junit_xml_object = cast(_Element, raw_obj) def validate(self) -> bool: - self.junit_schema_doc = etree.parse(self.junit_xsd_path) + self.junit_schema_doc = cast(_ElementTree, etree.parse(self.junit_xsd_path)) self.xmlschema = etree.XMLSchema(self.junit_schema_doc) - assert self.xmlschema is not None self.valid_xml = self.xmlschema.validate(self.junit_xml_doc) + return bool(self.valid_xml) - return self.valid_xml - - def parse(self) -> list[dict[str, Any]]: + def parse(self) -> List[Dict[str, object]]: """ Creates a common python list of object, no matter what information are supported by the parsed xml file for test results junit(). @@ -47,33 +50,31 @@ def parse(self) -> list[dict[str, Any]]: :return: list of test suites as dictionaries """ - def parse_testcase(xml_object: ObjectifiedElement) -> dict[str, Any]: - testcase = xml_object - - tc_dict = { - "classname": testcase.attrib.get("classname", "unknown"), - "file": testcase.attrib.get("file", "unknown"), - "line": int(testcase.attrib.get("line", -1)), - "name": testcase.attrib.get("name", "unknown"), - "time": float(testcase.attrib.get("time", -1)), + def parse_testcase(testcase: _Element) -> Dict[str, object]: + tc_dict: Dict[str, object] = { + "classname": str(testcase.attrib.get("classname", "unknown")), + "file": str(testcase.attrib.get("file", "unknown")), + "line": int(testcase.attrib.get("line", "-1")), + "name": str(testcase.attrib.get("name", "unknown")), + "time": float(testcase.attrib.get("time", "-1")), } # The following data is normally a subnode (e.g. skipped/failure). # We integrate it right into the testcase for better handling if hasattr(testcase, "skipped"): - result = testcase.skipped + skipped = cast(_Element, getattr(testcase, "skipped")) tc_dict["result"] = "skipped" - tc_dict["type"] = result.attrib.get("type", "unknown") + tc_dict["type"] = str(skipped.attrib.get("type", "unknown")) + tc_dict["text"] = str(skipped.text or "") # tc_dict["text"] = re.sub(r"[\n\t]*", "", result.text) # Removes newlines and tabs # result.text can be None for pytest xfail test cases - tc_dict["text"] = result.text or "" - tc_dict["message"] = result.attrib.get("message", "unknown") + tc_dict["message"] = str(skipped.attrib.get("message", "unknown")) elif hasattr(testcase, "failure"): - result = testcase.failure + failure = cast(_Element, getattr(testcase, "failure")) tc_dict["result"] = "failure" - tc_dict["type"] = result.attrib.get("type", "unknown") + tc_dict["type"] = str(failure.attrib.get("type", "unknown")) # tc_dict["text"] = re.sub(r"[\n\t]*", "", result.text) # Removes newlines and tabs - tc_dict["text"] = result.text + tc_dict["text"] = str(failure.text or "") tc_dict["message"] = "" else: tc_dict["result"] = "passed" @@ -82,68 +83,67 @@ def parse_testcase(xml_object: ObjectifiedElement) -> dict[str, Any]: tc_dict["message"] = "" if hasattr(testcase, "system-out"): - tc_dict["system-out"] = getattr(testcase, "system-out").text + sysout = cast(_Element, getattr(testcase, "system-out")) + tc_dict["system-out"] = str(sysout.text or "") else: tc_dict["system-out"] = "" return tc_dict - def parse_testsuite(xml_object: ObjectifiedElement) -> dict[str, Any]: - testsuite = xml_object - - tests = int(testsuite.attrib.get("tests", -1)) - errors = int(testsuite.attrib.get("errors", -1)) - failures = int(testsuite.attrib.get("failures", -1)) + def parse_testsuite(testsuite: _Element) -> Dict[str, object]: + tests = int(testsuite.attrib.get("tests", "-1")) + errors = int(testsuite.attrib.get("errors", "-1")) + failures = int(testsuite.attrib.get("failures", "-1")) # fmt: off - skips = int( - testsuite.attrib.get("skips") or testsuite.attrib.get("skip") or testsuite.attrib.get("skipped") or -1 + skips_str = ( + testsuite.attrib.get("skips") + or testsuite.attrib.get("skip") + or testsuite.attrib.get("skipped") + or "-1" ) # fmt: on - passed = int(tests - sum(x for x in [errors, failures, skips] if x > 0)) + skips = int(skips_str) + passed = max(0, tests - max(0, errors) - max(0, failures) - max(0, skips)) - ts_dict = { - "name": testsuite.attrib.get("name", "unknown"), + ts_dict: Dict[str, object] = { + "name": str(testsuite.attrib.get("name", "unknown")), "tests": tests, "errors": errors, "failures": failures, "skips": skips, "passed": passed, - "time": float(testsuite.attrib.get("time", -1)), + "time": float(testsuite.attrib.get("time", "-1")), "testcases": [], "testsuite_nested": [], } # add nested testsuite objects to if hasattr(testsuite, "testsuite"): - for ts in testsuite.testsuite: + nested_suites = cast(List[_Element], getattr(testsuite, "testsuite")) + for ts in nested_suites: # dict from inner parse - inner_testsuite = parse_testsuite(cast(ObjectifiedElement, ts)) - ts_dict["testsuite_nested"].append(inner_testsuite) + cast(List[Dict[str, object]], ts_dict["testsuite_nested"]).append(parse_testsuite(ts)) - elif hasattr(testsuite, "testcase"): - for tc in testsuite.testcase: - new_testcase = parse_testcase(cast(ObjectifiedElement, tc)) - ts_dict["testcases"].append(new_testcase) + if hasattr(testsuite, "testcase"): + testcases = cast(List[_Element], getattr(testsuite, "testcase")) + for tc in testcases: + cast(List[Dict[str, object]], ts_dict["testcases"]).append(parse_testcase(tc)) return ts_dict # main flow starts here - junit_dict = [] + junit_dict: List[Dict[str, object]] = [] if self.junit_xml_object.tag == "testsuites": - for testsuite_xml_object in self.junit_xml_object.testsuite: - complete_testsuite = parse_testsuite( - cast(ObjectifiedElement, testsuite_xml_object) - ) - junit_dict.append(complete_testsuite) + if hasattr(self.junit_xml_object, "testsuite"): + suites = cast(List[_Element], getattr(self.junit_xml_object, "testsuite")) + for ts in suites: + junit_dict.append(parse_testsuite(ts)) else: - complete_testsuite = parse_testsuite( - cast(ObjectifiedElement, self.junit_xml_object) - ) - junit_dict.append(complete_testsuite) + junit_dict.append(parse_testsuite(self.junit_xml_object)) return junit_dict @@ -151,5 +151,5 @@ def docutils_table(self) -> None: pass -class JUnitFileMissing(BaseException): +class JUnitFileMissing(Exception): pass From a19b4a0d8140350b92e20e744d2c5b2e4282eb95 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sun, 20 Jul 2025 19:27:53 +0200 Subject: [PATCH 06/29] Fix all mypy errors in test_results.py --- .../test_reports/directives/test_results.py | 101 +++++++++--------- 1 file changed, 53 insertions(+), 48 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_results.py b/sphinxcontrib/test_reports/directives/test_results.py index 46fc8fe..8ef1cd6 100644 --- a/sphinxcontrib/test_reports/directives/test_results.py +++ b/sphinxcontrib/test_reports/directives/test_results.py @@ -1,13 +1,15 @@ -import os +# sphinxcontrib/test_reports/directives/test_results.py +import os +from typing import List, Tuple, Dict, Union, cast from docutils import nodes from docutils.parsers.rst import Directive - from sphinxcontrib.test_reports.junitparser import JUnitParser +from sphinx.environment import BuildEnvironment -class TestResults(nodes.General, nodes.Element): - pass +TestcaseDict = Dict[str, Union[str, int, float]] +TestsuiteDict = Dict[str, Union[str, int, float, List[TestcaseDict], List["TestsuiteDict"]]] class TestResultsDirective(Directive): @@ -18,43 +20,39 @@ class TestResultsDirective(Directive): has_content = True required_arguments = 1 optional_arguments = 0 - final_argument_whitespace = True - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.header = ("class", "name", "status", "reason") - self.colwidths = (1, 1, 1, 2) - - def run(self): - env = self.state.document.settings.env + header: Tuple[str, str, str, str] = ("class", "name", "status", "reason") + colwidths: Tuple[int, int, int, int] = (1, 1, 1, 2) + def run(self) -> List[nodes.Element]: xml_path = self.arguments[0] - root_path = env.app.config.tr_rootdir + + env = cast(BuildEnvironment, self.state.document.settings.env) + root_path = cast(str, env.app.config.tr_rootdir) + if not os.path.isabs(xml_path): xml_path = os.path.join(root_path, xml_path) + parser = JUnitParser(xml_path) - results = parser.parse() + results = cast(List[TestsuiteDict], parser.parse()) # Construction idea taken from http://agateau.com/2015/docutils-snippets/ - main_section = [] + main_section: List[nodes.Element] = [] - for testsuite in results: + for ts in results: section = nodes.section() - section += nodes.title(text=testsuite["name"]) + section += nodes.title(text=str(ts.get("name", "unknown"))) section += nodes.paragraph( - text="Tests: {tests}, Failures: {failure}, Errors: {error}, " - "Skips: {skips}".format( - tests=testsuite["tests"], - failure=testsuite["failures"], - error=testsuite["errors"], - skips=testsuite["skips"], + text="Tests: {tests}, Failures: {failure}, Errors: {error}, Skips: {skips}".format( + tests=ts.get("tests", -1), + failure=ts.get("failures", -1), + error=ts.get("errors", -1), + skips=ts.get("skips", -1), ) ) - section += nodes.paragraph( - text="Time: {time}".format(time=testsuite["time"]) - ) + section += nodes.paragraph(text=f"Time: {ts.get('time', -1)}") table = nodes.table() section += table @@ -66,43 +64,50 @@ def run(self): thead = nodes.thead() tgroup += thead - thead += self._create_table_row(self.header) + thead += self._create_table_row(list(self.header)) tbody = nodes.tbody() tgroup += tbody - for testcase in testsuite["testcases"]: - tbody += self._create_testcase_row(testcase) - main_section += section + raw_testcases = ts.get("testcases", []) + if isinstance(raw_testcases, list): + for testcase in raw_testcases: + if isinstance(testcase, dict): + typed_testcase = cast(TestcaseDict, testcase) + tbody += self._create_testcase_row(typed_testcase) + + main_section.append(section) return main_section - def _create_testcase_row(self, testcase): - row_cells = ( - testcase["classname"], - testcase["name"], - testcase["result"], - "\n\n".join( - [ - testcase["message"] if testcase["message"] != "unknown" else "", - testcase["text"] if testcase["text"] is not None else "", - ] - ), + def _create_testcase_row(self, testcase: TestcaseDict) -> nodes.row: + reason_parts: List[str] = [] + if testcase.get("message") and testcase["message"] != "unknown": + reason_parts.append(str(testcase["message"])) + if testcase.get("text"): + reason_parts.append(str(testcase["text"])) + + row_cells: Tuple[str, str, str, str] = ( + str(testcase.get("classname", "")), + str(testcase.get("name", "")), + str(testcase.get("result", "")), + "\n\n".join(reason_parts), ) - row = nodes.row(classes=["tr_" + testcase["result"]]) + result_class = "tr_" + row_cells[2] + row = nodes.row(classes=cast(List[str], [result_class])) + for index, cell in enumerate(row_cells): - entry = nodes.entry( - classes=["tr_" + testcase["result"], self.header[index]] - ) + entry = nodes.entry(classes=cast(List[str], [result_class, self.header[index]])) + entry += nodes.paragraph(text=cell, classes=cast(List[str], [result_class])) row += entry - entry += nodes.paragraph(text=cell, classes=["tr_" + testcase["result"]]) + return row - def _create_table_row(self, row_cells): + def _create_table_row(self, row_cells: List[str]) -> nodes.row: row = nodes.row() for cell in row_cells: entry = nodes.entry() - row += entry entry += nodes.paragraph(text=cell) + row += entry return row From 612c65f6ed24270cdb08046be1b5bff26f28735b Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:49:38 +0200 Subject: [PATCH 07/29] fix mypy error in environment.py --- sphinxcontrib/test_reports/environment.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/sphinxcontrib/test_reports/environment.py b/sphinxcontrib/test_reports/environment.py index 335b18b..afd0164 100644 --- a/sphinxcontrib/test_reports/environment.py +++ b/sphinxcontrib/test_reports/environment.py @@ -1,4 +1,5 @@ import os +from typing import TYPE_CHECKING, Optional, Any, List import sphinx from packaging.version import Version @@ -15,7 +16,7 @@ STATICS_DIR_NAME = "_static" -def safe_add_file(filename, app): +def safe_add_file(filename: str, app: Any) -> None: """ Adds files to builder resources only, if the given filename was not already registered. Needed mainly for tests to avoid multiple registration of the same file and therefore also multiple execution @@ -25,8 +26,8 @@ def safe_add_file(filename, app): :param app: app object :return: None """ - data_file = filename - static_data_file = os.path.join("_static", data_file) + data_file: str = filename + static_data_file: str = os.path.join("_static", data_file) if data_file.split(".")[-1] == "js": if ( @@ -46,7 +47,7 @@ def safe_add_file(filename, app): ) -def safe_remove_file(filename, app): +def safe_remove_file(filename: str, app: Any) -> None: """ Removes a given resource file from builder resources. Needed mostly during test, if multiple sphinx-build are started. @@ -56,8 +57,8 @@ def safe_remove_file(filename, app): :param app: app object :return: None """ - data_file = filename - static_data_file = os.path.join("_static", data_file) + data_file: str = filename + static_data_file: str = os.path.join("_static", data_file) if data_file.split(".")[-1] == "js": if ( @@ -73,11 +74,11 @@ def safe_remove_file(filename, app): # Base implementation from sphinxcontrib-images # https://github.com/spinus/sphinxcontrib-images/blob/master/sphinxcontrib/images.py#L203 -def install_styles_static_files(app, env): - statics_dir_path = os.path.join(app.builder.outdir, STATICS_DIR_NAME) - dest_path = os.path.join(statics_dir_path, "sphinx-test-results") +def install_styles_static_files(app: Any, env: Any) -> None: + statics_dir_path: str = os.path.join(app.builder.outdir, STATICS_DIR_NAME) + dest_path: str = os.path.join(statics_dir_path, "sphinx-test-results") - files_to_copy = ["common.css"] + files_to_copy: List[str] = ["common.css"] # Be sure no "old" css layout is already set safe_remove_file("sphinx-test-reports/common.css", app) @@ -103,7 +104,7 @@ def install_styles_static_files(app, env): ) print(f"{source_file_path} not found. Copying sphinx-internal blank.css") - dest_file_path = os.path.join(dest_path, os.path.basename(source_file_path)) + dest_file_path: str = os.path.join(dest_path, os.path.basename(source_file_path)) if not os.path.exists(os.path.dirname(dest_file_path)): ensuredir(os.path.dirname(dest_file_path)) From f3234b8501053d93d5ef556ee2a12d53b7820e0a Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:50:50 +0200 Subject: [PATCH 08/29] fix mypy error in exceptions.py --- sphinxcontrib/test_reports/exceptions.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sphinxcontrib/test_reports/exceptions.py b/sphinxcontrib/test_reports/exceptions.py index 171f3ba..dfce0ff 100644 --- a/sphinxcontrib/test_reports/exceptions.py +++ b/sphinxcontrib/test_reports/exceptions.py @@ -1,4 +1,14 @@ -from sphinx.errors import SphinxError, SphinxWarning +from sphinx.errors import SphinxError, SphinxWarning + +__all__ = [ + "SphinxError", + "SphinxWarning", + "TestReportFileNotSetError", + "TestReportFileInvalidError", + "TestReportInvalidOptionError", + "TestReportIncompleteConfigurationError", + "InvalidConfigurationError", +] class TestReportFileNotSetError(SphinxError): From 821e126c7b844c068e549050e9bb9b4b0b4b0fcc Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:51:54 +0200 Subject: [PATCH 09/29] fix mypy error test_reports.py --- sphinxcontrib/test_reports/test_reports.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/sphinxcontrib/test_reports/test_reports.py b/sphinxcontrib/test_reports/test_reports.py index dd505fb..4d8b28a 100644 --- a/sphinxcontrib/test_reports/test_reports.py +++ b/sphinxcontrib/test_reports/test_reports.py @@ -28,18 +28,20 @@ from sphinxcontrib.test_reports.environment import install_styles_static_files from sphinxcontrib.test_reports.functions import tr_link +from typing import Any, Dict, List, Optional + +import logging sphinx_version = sphinx.__version__ if Version(sphinx_version) >= Version("1.6"): - from sphinx.util import logging -else: - import logging + from sphinx.util import logging as sphinx_logging + logging = sphinx_logging # fmt: on VERSION = "1.1.1" -def setup(app: Sphinx): +def setup(app: Sphinx) -> dict[str, object]: """ Setup following directives: * test_results @@ -135,11 +137,11 @@ def setup(app: Sphinx): } -def register_tr_extra_options(app): +def register_tr_extra_options(app: Sphinx) -> None: """Register extra options with directives.""" log = logging.getLogger(__name__) - tr_extra_options = getattr(app.config, "tr_extra_options", []) + tr_extra_options: list[str] = getattr(app.config, "tr_extra_options", []) log.debug(f"tr_extra_options = {tr_extra_options}") if tr_extra_options: @@ -152,12 +154,12 @@ def register_tr_extra_options(app): ) -def tr_preparation(app, *args): +def tr_preparation(app: Sphinx, *args: object) -> None: """ Prepares needed vars in the app context. """ if not hasattr(app, "tr_types"): - app.tr_types = {} + setattr(app, "tr_types", {}) # Collects the configured test-report node types app.tr_types[app.config.tr_file[0]] = app.config.tr_file[1:] From aaa35edad638d60de3197b1a10a07b2970bd0286 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:53:18 +0200 Subject: [PATCH 10/29] fix mypy error test_case.py --- .../test_reports/directives/test_case.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_case.py b/sphinxcontrib/test_reports/directives/test_case.py index 39ef4cd..44a38d9 100644 --- a/sphinxcontrib/test_reports/directives/test_case.py +++ b/sphinxcontrib/test_reports/directives/test_case.py @@ -7,6 +7,8 @@ from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import TestReportInvalidOptionError +from typing import Dict, List, Optional, Match + class TestCase(nodes.General, nodes.Element): pass @@ -34,16 +36,17 @@ class TestCaseDirective(TestCommonDirective): final_argument_whitespace = True - def __init__(self, *args, **kwargs): + def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - def run(self, nested=False, suite_count=-1, case_count=-1): + def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) -> List[object]: self.prepare_basic_options() self.load_test_file() if nested and suite_count >= 0: # access n-th nested suite here - self.results = self.results[0]["testsuites"][suite_count] + if self.results and isinstance(self.results, list) and len(self.results) > 0: + self.results = self.results[0]["testsuites"][suite_count] # type: ignore[index] suite_name = self.options.get("suite") @@ -73,7 +76,7 @@ def run(self, nested=False, suite_count=-1, case_count=-1): case = None for case_obj in suite["testcases"]: - if case_obj["name"] == case_full_name and class_name is None: # noqa: SIM114 # noqa: W503 + if case_obj["name"] == case_full_name and class_name is None: case = case_obj break @@ -100,8 +103,8 @@ def run(self, nested=False, suite_count=-1, case_count=-1): ) result = case["result"] - content = self.test_content - if case["text"] is not None and len(case["text"]) > 0: + content = self.test_content or "" + if case.get("text") is not None and isinstance(case["text"], str) and len(case["text"]) > 0: content += """ **Text**:: @@ -110,7 +113,7 @@ def run(self, nested=False, suite_count=-1, case_count=-1): """.format("\n ".join([x.lstrip() for x in case["text"].split("\n")])) - if case["message"] is not None and len(case["message"]) > 0: + if case.get("message") is not None and isinstance(case["message"], str) and len(case["message"]) > 0: content += """ **Message**:: @@ -119,7 +122,7 @@ def run(self, nested=False, suite_count=-1, case_count=-1): """.format("\n ".join([x.lstrip() for x in case["message"].split("\n")])) - if case["system-out"] is not None and len(case["system-out"]) > 0: + if case.get("system-out") is not None and isinstance(case["system-out"], str) and len(case["system-out"]) > 0: content += """ **System-out**:: @@ -129,15 +132,15 @@ def run(self, nested=False, suite_count=-1, case_count=-1): """.format("\n ".join([x.lstrip() for x in case["system-out"].split("\n")])) time = case["time"] - style = "tr_" + case["result"] + style = "tr_" + str(case["result"]) import re - groups = re.match(r"^(?P[^\[]+)($|\[(?P.*)?\])", case["name"]) - try: + groups: Optional[Match[str]] = re.match(r"^(?P[^\[]+)($|\[(?P.*)?\])", case["name"]) + if groups is not None: case_name = groups["name"] case_parameter = groups["param"] - except TypeError: + else: case_name = case_full_name case_parameter = "" @@ -151,14 +154,15 @@ def run(self, nested=False, suite_count=-1, case_count=-1): elif key == "status" and value not in ["", None]: self.test_status = str(value) elif key == "tags": - self.test_tags = ",".join([self.test_tags, str(value)]) + self.test_tags = ",".join([self.test_tags or "", str(value)]) elif key not in DEFAULT_OPTIONS and value not in ["", None]: # May overwrite globally set values - self.extra_options[key] = str(value) + if self.extra_options is not None: + self.extra_options[key] = str(value) docname = self.state.document.settings.env.docname - main_section = [] + main_section: List[object] = [] # Merge all options including extra ones main_section += add_need( self.app, From 2dcd656bb9d8d002514eb06b569a6c08717f67bf Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:54:22 +0200 Subject: [PATCH 11/29] fix mypy error test_common.py --- .../test_reports/directives/test_common.py | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_common.py b/sphinxcontrib/test_reports/directives/test_common.py index a0300ed..0d0fd1d 100644 --- a/sphinxcontrib/test_reports/directives/test_common.py +++ b/sphinxcontrib/test_reports/directives/test_common.py @@ -6,6 +6,7 @@ import os import pathlib from importlib.metadata import version +from typing import Any, Dict, Optional from docutils.parsers.rst import Directive from sphinx.util import logging @@ -34,40 +35,40 @@ class TestCommonDirective(Directive): Common directive, which provides some shared functions to "real" directives. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.env = self.state.document.settings.env - self.app = self.env.app + self.env: Any = self.state.document.settings.env + self.app: Any = self.env.app if not hasattr(self.app, "testreport_data"): - self.app.testreport_data = {} - - self.test_file = None - self.results = None - self.docname = None - self.test_name = None - self.test_id = None - self.test_content = None - self.test_file_given = None - self.test_links = None - self.test_tags = None - self.test_status = None - self.collapse = None - self.need_type = None - self.extra_options = None + self.app.testreport_data: Dict[str, Any] = {} + + self.test_file: Optional[str] = None + self.results: Optional[Any] = None + self.docname: Optional[str] = None + self.test_name: Optional[str] = None + self.test_id: Optional[str] = None + self.test_content: Optional[str] = None + self.test_file_given: Optional[str] = None + self.test_links: Optional[str] = None + self.test_tags: Optional[str] = None + self.test_status: Optional[str] = None + self.collapse: Optional[Any] = None + self.need_type: Optional[str] = None + self.extra_options: Optional[Dict[str, Any]] = None self.log = logging.getLogger(__name__) - def collect_extra_options(self): + def collect_extra_options(self) -> None: """Collect any extra options and their values that were specified in the directive""" tr_extra_options = getattr(self.app.config, "tr_extra_options", []) - self.extra_options = {} + self.extra_options: Dict[str, Any] = {} if tr_extra_options: for option_name in tr_extra_options: if option_name in self.options: self.extra_options[option_name] = self.options[option_name] - def load_test_file(self): + def load_test_file(self) -> Optional[Any]: """ Loads the defined test_file under self.test_file. @@ -101,7 +102,7 @@ def load_test_file(self): self.results = self.app.testreport_data[self.test_file] return self.results - def prepare_basic_options(self): + def prepare_basic_options(self) -> None: """ Reads and checks the needed basic data like name, id, links, status, ... :return: None From ee021979dcd72f028434f7d975bb3bb9e7ecd16a Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:55:38 +0200 Subject: [PATCH 12/29] fix mypy error test_env.py --- .../test_reports/directives/test_env.py | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_env.py b/sphinxcontrib/test_reports/directives/test_env.py index a3901de..3af883a 100644 --- a/sphinxcontrib/test_reports/directives/test_env.py +++ b/sphinxcontrib/test_reports/directives/test_env.py @@ -6,6 +6,7 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives from packaging.version import Version +from typing import Any, List, Optional, Tuple sphinx_version = sphinx.__version__ if Version(sphinx_version) >= Version("1.6"): @@ -37,14 +38,14 @@ class EnvReportDirective(Directive): final_argument_whitespace = True - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.data_option = self.options.get("data") - self.environments = self.options.get("env") + self.data_option: Optional[str] = self.options.get("data") + self.environments: Optional[str] = self.options.get("env") if self.environments is not None: - self.req_env_list_cpy = self.environments.split(",") - self.req_env_list = [] + self.req_env_list_cpy: List[str] = self.environments.split(",") + self.req_env_list: Optional[List[str]] = [] for element in self.req_env_list_cpy: if len(element) != 0: self.req_env_list.append(element.lstrip().rstrip()) @@ -52,18 +53,18 @@ def __init__(self, *args, **kwargs): self.req_env_list = None if self.data_option is not None: - self.data_option_list_cpy = self.data_option.split(",") - self.data_option_list = [] + self.data_option_list_cpy: List[str] = self.data_option.split(",") + self.data_option_list: Optional[List[str]] = [] for element in self.data_option_list_cpy: if len(element) != 0: self.data_option_list.append(element.rstrip().lstrip()) else: self.data_option_list = None - self.header = ("Variable", "Data") - self.colwidths = (1, 1) + self.header: Tuple[str, str] = ("Variable", "Data") + self.colwidths: Tuple[int, int] = (1, 1) - def run(self): + def run(self) -> List[Any]: env = self.state.document.settings.env json_path = self.arguments[0] @@ -95,7 +96,7 @@ def run(self): del not_present_env # Construction idea taken from http://agateau.com/2015/docutils-snippets/ - main_section = [] + main_section: List[Any] = [] if self.req_env_list is None and "raw" not in self.options: for enviro in results: @@ -162,8 +163,8 @@ def run(self): return main_section - def _crete_table_b(self, enviro, results): - main_section = [] + def _crete_table_b(self, enviro: str, results: Any) -> List[Any]: + main_section: List[Any] = [] section = nodes.section() section += nodes.title(text=enviro) @@ -203,7 +204,7 @@ def _crete_table_b(self, enviro, results): return main_section - def _create_rows(self, row_cells): + def _create_rows(self, row_cells: Any) -> Any: row = nodes.row() for cell in row_cells: entry = nodes.entry() @@ -218,13 +219,13 @@ def _create_rows(self, row_cells): return row -class InvalidJsonFile(BaseException): +class InvalidJsonFile(Exception): pass -class JsonFileNotFound(BaseException): +class JsonFileNotFound(Exception): pass -class InvalidEnvRequested(BaseException): +class InvalidEnvRequested(Exception): pass From 25c0ddd23549426e31a914376222832880062ed1 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:56:48 +0200 Subject: [PATCH 13/29] fix mypy error test_flie.py --- .../test_reports/directives/test_file.py | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_file.py b/sphinxcontrib/test_reports/directives/test_file.py index 38741ac..a66f174 100644 --- a/sphinxcontrib/test_reports/directives/test_file.py +++ b/sphinxcontrib/test_reports/directives/test_file.py @@ -9,6 +9,8 @@ from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import TestReportIncompleteConfigurationError +from typing import Dict, List, Optional, Any + class TestFile(nodes.General, nodes.Element): pass @@ -35,35 +37,34 @@ class TestFileDirective(TestCommonDirective): final_argument_whitespace = True - def __init__(self, *args, **kwargs): + def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - self.suite_ids = {} + self.suite_ids: Dict[str, str] = {} - def run(self): + def run(self) -> List[object]: self.prepare_basic_options() - results = self.load_test_file() + results: Optional[List[Dict[str, object]]] = self.load_test_file() # Error handling, if file not found if results is None: - main_section = [] + main_section: List[object] = [] content = nodes.error() para = nodes.paragraph() text_string = f"Test file not found: {self.test_file}" - text = nodes.Text(text_string, text_string) + text = nodes.Text(text_string) para += text content.append(para) main_section.append(content) return main_section - suites = len(self.results) - cases = sum(int(x["tests"]) for x in self.results) - - passed = sum(x["passed"] for x in self.results) - skipped = sum(x["skips"] for x in self.results) - errors = sum(x["errors"] for x in self.results) - failed = sum(x["failures"] for x in self.results) + suites = len(self.results) if self.results is not None else 0 + cases = sum(int(x["tests"]) for x in self.results) if self.results is not None else 0 + passed = sum(x["passed"] for x in self.results) if self.results is not None else 0 + skipped = sum(x["skips"] for x in self.results) if self.results is not None else 0 + errors = sum(x["errors"] for x in self.results) if self.results is not None else 0 + failed = sum(x["failures"] for x in self.results) if self.results is not None else 0 - main_section = [] + main_section: List[object] = [] docname = self.state.document.settings.env.docname main_section += add_need( self.app, @@ -97,9 +98,9 @@ def run(self): "auto_suites for test-file directives." ) - if "auto_suites" in self.options.keys(): + if "auto_suites" in self.options.keys() and self.results is not None: for suite in self.results: - suite_id = self.test_id + suite_id = self.test_id or "" suite_id += ( "_" + hashlib.sha1(suite["name"].encode("UTF-8")) @@ -114,13 +115,13 @@ def run(self): f"Suite ID {suite_id} already exists by {self.suite_ids[suite_id]} ({suite['name']})" ) - options = self.options + options = self.options.copy() options["suite"] = suite["name"] options["id"] = suite_id - if "links" not in self.options: - options["links"] = self.test_id - elif self.test_id not in options["links"]: + if "links" not in options: + options["links"] = self.test_id or "" + elif self.test_id and self.test_id not in options["links"]: options["links"] = options["links"] + ";" + self.test_id arguments = [suite["name"]] From 4e4a5118f7a0accbd5ab12fc7674bf5521cc963d Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:57:50 +0200 Subject: [PATCH 14/29] fix mypy error test_report.py --- sphinxcontrib/test_reports/directives/test_report.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_report.py b/sphinxcontrib/test_reports/directives/test_report.py index 57da891..0f7f67d 100644 --- a/sphinxcontrib/test_reports/directives/test_report.py +++ b/sphinxcontrib/test_reports/directives/test_report.py @@ -3,6 +3,7 @@ from docutils import nodes from docutils.parsers.rst import directives +from typing import Any, List from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import InvalidConfigurationError @@ -33,10 +34,10 @@ class TestReportDirective(TestCommonDirective): final_argument_whitespace = True - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - def run(self): + def run(self) -> List[Any]: self.prepare_basic_options() self.load_test_file() From 07ceed5d573d24837d9c1e8cfe516298adebd180 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:59:05 +0200 Subject: [PATCH 15/29] fix mypy error test_suite.py --- .../test_reports/directives/test_suite.py | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_suite.py b/sphinxcontrib/test_reports/directives/test_suite.py index 13b0411..9a69734 100644 --- a/sphinxcontrib/test_reports/directives/test_suite.py +++ b/sphinxcontrib/test_reports/directives/test_suite.py @@ -9,6 +9,8 @@ from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import TestReportInvalidOptionError +from typing import Dict, List, Optional + class TestSuite(nodes.General, nodes.Element): pass @@ -34,17 +36,17 @@ class TestSuiteDirective(TestCommonDirective): final_argument_whitespace = True - def __init__(self, *args, **kwargs): + def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - self.case_ids = [] + self.case_ids: List[str] = [] - def run(self, nested=False, count=-1): + def run(self, nested: bool = False, count: int = -1) -> List[object]: self.prepare_basic_options() self.load_test_file() if nested: # access n-th nested suite here - self.results = self.results[0]["testsuite_nested"] + self.results = self.results[0]["testsuite_nested"] # type: ignore[index] suite_name = self.options.get("suite") @@ -66,14 +68,13 @@ def run(self, nested=False, count=-1): f"Suite {suite_name} not found in test file {self.test_file}" ) - cases = suite["tests"] - - passed = suite["passed"] - skipped = suite["skips"] - errors = suite["errors"] - failed = suite["failures"] + cases = suite["tests"] if "tests" in suite else 0 + passed = suite["passed"] if "passed" in suite else 0 + skipped = suite["skips"] if "skips" in suite else 0 + errors = suite["errors"] if "errors" in suite else 0 + failed = suite["failures"] if "failures" in suite else 0 - main_section = [] + main_section: List[object] = [] docname = self.state.document.settings.env.docname main_section += add_need( self.app, @@ -101,26 +102,26 @@ def run(self, nested=False, count=-1): # TODO double nested logic # nested testsuite present, if testcases are present -> reached most inner testsuite access_count = 0 - if len(suite_obj["testcases"]) == 0: - for suite in suite_obj["testsuite_nested"]: - suite_id = self.test_id + if "testcases" in suite_obj and isinstance(suite_obj["testcases"], list) and len(suite_obj["testcases"]) == 0: + for nested_suite in suite_obj.get("testsuite_nested", []): + suite_id = (self.test_id or "") suite_id += ( "_" - + hashlib.sha1(suite["name"].encode("UTF-8")) + + hashlib.sha1(nested_suite["name"].encode("UTF-8")) .hexdigest() .upper()[: self.app.config.tr_suite_id_length] ) - options = self.options - options["suite"] = suite["name"] + options = self.options.copy() + options["suite"] = nested_suite["name"] options["id"] = suite_id - if "links" not in self.options: - options["links"] = self.test_id - elif self.test_id not in options["links"]: + if "links" not in options: + options["links"] = self.test_id or "" + elif self.test_id and self.test_id not in options["links"]: options["links"] = options["links"] + ";" + self.test_id - arguments = [suite["name"]] + arguments = [nested_suite["name"]] suite_directive = ( sphinxcontrib.test_reports.directives.test_suite.TestSuiteDirective( self.app.config.tr_suite[0], @@ -135,7 +136,7 @@ def run(self, nested=False, count=-1): ) ) - is_nested = len(suite_obj["testsuites"]) > 0 + is_nested = len(suite_obj.get("testsuites", [])) > 0 # create suite_directive for each nested suite, directive appends content in html files # access_count keeps track of which nested testsuite to access in the directive @@ -143,11 +144,11 @@ def run(self, nested=False, count=-1): access_count += 1 # suite has testcases - if "auto_cases" in self.options.keys() and len(suite_obj["testcases"]) > 0: + if "auto_cases" in self.options.keys() and "testcases" in suite and isinstance(suite["testcases"], list) and len(suite["testcases"]) > 0: case_count = 0 for case in suite["testcases"]: - case_id = self.test_id + case_id = (self.test_id or "") case_id += ( "_" + hashlib.sha1( @@ -170,9 +171,9 @@ def run(self, nested=False, count=-1): options["classname"] = case["classname"] options["id"] = case_id - if "links" not in self.options: - options["links"] = self.test_id - elif self.test_id not in options["links"]: + if "links" not in options: + options["links"] = self.test_id or "" + elif self.test_id and self.test_id not in options["links"]: options["links"] = options["links"] + ";" + self.test_id arguments = [case["name"]] @@ -190,7 +191,7 @@ def run(self, nested=False, count=-1): ) ) - is_nested = len(suite_obj["testsuite_nested"]) > 0 or nested + is_nested = ("testsuite_nested" in suite_obj and isinstance(suite_obj["testsuite_nested"], list) and len(suite_obj["testsuite_nested"]) > 0) or nested # depending if nested or not, runs case directive to add content to testcases # count is for correct suite access, if multiple present, case_count is for correct case access From 1f8c5d0b8af689febdc5dbe34deb2bfac1bb9ff8 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Wed, 6 Aug 2025 17:59:52 +0200 Subject: [PATCH 16/29] fix mypy error __init__.py --- .../test_reports/functions/__init__.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/sphinxcontrib/test_reports/functions/__init__.py b/sphinxcontrib/test_reports/functions/__init__.py index 79d4896..3c243fe 100644 --- a/sphinxcontrib/test_reports/functions/__init__.py +++ b/sphinxcontrib/test_reports/functions/__init__.py @@ -1,16 +1,26 @@ -def tr_link(app, need, needs, test_option, target_option, *args, **kwargs): +from typing import List, Dict + + +def tr_link( + app: object, + need: Dict[str, str], + needs: Dict[str, Dict[str, str]], + test_option: str, + target_option: str, + *args: object, + **kwargs: object +) -> List[str]: if test_option not in need: - return "" + return [] # Allow for multiple values in option - test_opt_values = need[test_option].split(",") + test_opt_values: List[str] = [s.strip() for s in need[test_option].split(",")] - links = [] + links: List[str] = [] for need_target in needs.values(): if target_option not in need_target: continue - for test_opt_raw in test_opt_values: - test_opt = test_opt_raw.strip() + for test_opt in test_opt_values: if ( test_opt == need_target[target_option] and test_opt is not None From 6a3849dd01808aece9f7d1911be1df129884318c Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 17:23:54 +0200 Subject: [PATCH 17/29] fix mypy error in environment --- sphinxcontrib/test_reports/environment.py | 82 ++++++++++++++--------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/sphinxcontrib/test_reports/environment.py b/sphinxcontrib/test_reports/environment.py index afd0164..98b82b7 100644 --- a/sphinxcontrib/test_reports/environment.py +++ b/sphinxcontrib/test_reports/environment.py @@ -1,22 +1,32 @@ import os -from typing import TYPE_CHECKING, Optional, Any, List +from typing import TYPE_CHECKING, Optional, List, Callable, Iterable, cast import sphinx from packaging.version import Version -from sphinx.util.console import brown +from sphinx.util import console as _sphinx_console from sphinx.util.osutil import copyfile, ensuredir +brown = cast(object, getattr(_sphinx_console, "brown", "")) + sphinx_version = sphinx.__version__ if Version(sphinx_version) >= Version("1.6"): - if Version(sphinx_version) >= Version("6.1"): - from sphinx.util.display import status_iterator - else: - from sphinx.util import status_iterator # noqa: F401 # Sphinx 1.5 + try: + from sphinx.util.display import status_iterator as _status_iterator + except Exception: + from sphinx.util import status_iterator as _status_iterator + +StatusIteratorType = Callable[[Iterable[str], str, object, int], Iterable[str]] + +status_iterator_typed: Optional[StatusIteratorType] +if Version(sphinx_version) >= Version("1.6"): + status_iterator_typed = cast(StatusIteratorType, _status_iterator) +else: + status_iterator_typed = None STATICS_DIR_NAME = "_static" -def safe_add_file(filename: str, app: Any) -> None: +def safe_add_file(filename: str, app: object) -> None: """ Adds files to builder resources only, if the given filename was not already registered. Needed mainly for tests to avoid multiple registration of the same file and therefore also multiple execution @@ -29,25 +39,28 @@ def safe_add_file(filename: str, app: Any) -> None: data_file: str = filename static_data_file: str = os.path.join("_static", data_file) + raw_builder = getattr(app, "builder", None) + builder: object = cast(object, raw_builder) + script_files = cast(Optional[List[str]], getattr(builder, "script_files", None)) + css_files = cast(Optional[List[str]], getattr(builder, "css_files", None)) + if data_file.split(".")[-1] == "js": - if ( - hasattr(app.builder, "script_files") - and static_data_file not in app.builder.script_files - ): - app.add_js_file(data_file) + if script_files is not None and static_data_file not in script_files: + add_js_file_fn = cast(Optional[Callable[[str], object]], getattr(cast(object, app), "add_js_file", None)) + if add_js_file_fn is not None: + add_js_file_fn(data_file) elif data_file.split(".")[-1] == "css": - if ( - hasattr(app.builder, "css_files") - and static_data_file not in app.builder.css_files - ): - app.add_css_file(data_file) + if css_files is not None and static_data_file not in css_files: + add_css_file_fn = cast(Optional[Callable[[str], object]], getattr(cast(object, app), "add_css_file", None)) + if add_css_file_fn is not None: + add_css_file_fn(data_file) else: raise NotImplementedError( "File type {} not support by save_add_file".format(data_file.split(".")[-1]) ) -def safe_remove_file(filename: str, app: Any) -> None: +def safe_remove_file(filename: str, app: object) -> None: """ Removes a given resource file from builder resources. Needed mostly during test, if multiple sphinx-build are started. @@ -60,22 +73,25 @@ def safe_remove_file(filename: str, app: Any) -> None: data_file: str = filename static_data_file: str = os.path.join("_static", data_file) + raw_builder = getattr(app, "builder", None) + builder: object = cast(object, raw_builder) + script_files = cast(Optional[List[str]], getattr(builder, "script_files", None)) + css_files = cast(Optional[List[str]], getattr(builder, "css_files", None)) + if data_file.split(".")[-1] == "js": - if ( - hasattr(app.builder, "script_files") - and static_data_file in app.builder.script_files - ): - app.builder.script_files.remove(static_data_file) - elif data_file.split(".")[-1] == "css" and ( - hasattr(app.builder, "css_files") and static_data_file in app.builder.css_files - ): - app.builder.css_files.remove(static_data_file) + if script_files is not None and static_data_file in script_files: + script_files.remove(static_data_file) + elif data_file.split(".")[-1] == "css": + if css_files is not None and static_data_file in css_files: + css_files.remove(static_data_file) # Base implementation from sphinxcontrib-images # https://github.com/spinus/sphinxcontrib-images/blob/master/sphinxcontrib/images.py#L203 -def install_styles_static_files(app: Any, env: Any) -> None: - statics_dir_path: str = os.path.join(app.builder.outdir, STATICS_DIR_NAME) +def install_styles_static_files(app: object, env: object) -> None: + builder_obj: object = cast(object, getattr(app, "builder")) + outdir = cast(str, getattr(builder_obj, "outdir")) + statics_dir_path: str = os.path.join(outdir, STATICS_DIR_NAME) dest_path: str = os.path.join(statics_dir_path, "sphinx-test-results") files_to_copy: List[str] = ["common.css"] @@ -84,10 +100,12 @@ def install_styles_static_files(app: Any, env: Any) -> None: safe_remove_file("sphinx-test-reports/common.css", app) if Version(sphinx_version) < Version("1.6"): - global status_iterator - status_iterator = app.status_iterator + global status_iterator_typed + status_it = getattr(cast(object, app), "status_iterator") + status_iterator_typed = cast(StatusIteratorType, status_it) - for source_file_path in status_iterator( + iterator = cast(StatusIteratorType, status_iterator_typed) + for source_file_path in iterator( files_to_copy, "Copying static files for sphinx-test-results custom style support...", brown, From 4536bec29bbf7917129c0ac4f5649d832f5bd758 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 17:31:27 +0200 Subject: [PATCH 18/29] fix mypy error on test_reports.py --- sphinxcontrib/test_reports/test_reports.py | 117 ++++++++++++--------- 1 file changed, 67 insertions(+), 50 deletions(-) diff --git a/sphinxcontrib/test_reports/test_reports.py b/sphinxcontrib/test_reports/test_reports.py index 4d8b28a..22ccc90 100644 --- a/sphinxcontrib/test_reports/test_reports.py +++ b/sphinxcontrib/test_reports/test_reports.py @@ -28,13 +28,22 @@ from sphinxcontrib.test_reports.environment import install_styles_static_files from sphinxcontrib.test_reports.functions import tr_link -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Protocol, cast + +class LoggerProtocol(Protocol): + def debug(self, msg: str) -> object: ... + def info(self, msg: str) -> object: ... + def warning(self, msg: str) -> object: ... + def error(self, msg: str) -> object: ... -import logging sphinx_version = sphinx.__version__ if Version(sphinx_version) >= Version("1.6"): from sphinx.util import logging as sphinx_logging - logging = sphinx_logging + logger: LoggerProtocol = cast(LoggerProtocol, sphinx_logging.getLogger(__name__)) +else: + import logging as std_logging + std_logging.basicConfig() + logger: LoggerProtocol = cast(LoggerProtocol, std_logging.getLogger(__name__)) # fmt: on @@ -49,24 +58,24 @@ def setup(app: Sphinx) -> dict[str, object]: * test_report """ - log = logging.getLogger(__name__) + log = logger log.info("Setting up sphinx-test-reports extension") # configurations - app.add_config_value("tr_rootdir", app.confdir, "html") + app.add_config_value("tr_rootdir", cast(str, app.confdir), "html") app.add_config_value( "tr_file", - ["test-file", "testfile", "Test-File", "TF_", "#ffffff", "node"], + cast(List[str], ["test-file", "testfile", "Test-File", "TF_", "#ffffff", "node"]), "html", ) app.add_config_value( "tr_suite", - ["test-suite", "testsuite", "Test-Suite", "TS_", "#cccccc", "folder"], + cast(List[str], ["test-suite", "testsuite", "Test-Suite", "TS_", "#cccccc", "folder"]), "html", ) app.add_config_value( "tr_case", - ["test-case", "testcase", "Test-Case", "TC_", "#999999", "rectangle"], + cast(List[str], ["test-case", "testcase", "Test-Case", "TC_", "#999999", "rectangle"]), "html", ) @@ -79,7 +88,7 @@ def setup(app: Sphinx) -> dict[str, object]: app.add_config_value("tr_suite_id_length", 3, "html") app.add_config_value("tr_case_id_length", 5, "html") app.add_config_value("tr_import_encoding", "utf8", "html") - app.add_config_value("tr_extra_options", [], "env") + app.add_config_value("tr_extra_options", cast(List[str], []), "env") json_mapping = { "json_config": { @@ -111,24 +120,24 @@ def setup(app: Sphinx) -> dict[str, object]: app.add_config_value("tr_json_mapping", json_mapping, "html", types=[dict]) # nodes - app.add_node(TestResults) - app.add_node(TestFile) - app.add_node(TestSuite) - app.add_node(TestCase) - app.add_node(TestReport) - app.add_node(EnvReport) + cast(object, app.add_node(TestResults)) + cast(object, app.add_node(TestFile)) + cast(object, app.add_node(TestSuite)) + cast(object, app.add_node(TestCase)) + cast(object, app.add_node(TestReport)) + cast(object, app.add_node(EnvReport)) # directives - app.add_directive("test-results", TestResultsDirective) - app.add_directive("test-env", EnvReportDirective) - app.add_directive("test-report", TestReportDirective) + cast(object, app.add_directive("test-results", TestResultsDirective)) + cast(object, app.add_directive("test-env", EnvReportDirective)) + cast(object, app.add_directive("test-report", TestReportDirective)) # events - app.connect("env-updated", install_styles_static_files) - app.connect("config-inited", tr_preparation) - app.connect("config-inited", sphinx_needs_update) + cast(object, app.connect("env-updated", install_styles_static_files)) + cast(object, app.connect("config-inited", tr_preparation)) + cast(object, app.connect("config-inited", sphinx_needs_update)) - app.connect("builder-inited", register_tr_extra_options) + cast(object, app.connect("builder-inited", register_tr_extra_options)) return { "version": VERSION, # identifies the version of our extension @@ -140,17 +149,18 @@ def setup(app: Sphinx) -> dict[str, object]: def register_tr_extra_options(app: Sphinx) -> None: """Register extra options with directives.""" - log = logging.getLogger(__name__) - tr_extra_options: list[str] = getattr(app.config, "tr_extra_options", []) + log = logger + tr_extra_options = cast(List[str], getattr(app.config, "tr_extra_options", [])) log.debug(f"tr_extra_options = {tr_extra_options}") if tr_extra_options: for direc in [TestSuiteDirective, TestFileDirective, TestCaseDirective]: for option_name in tr_extra_options: - direc.option_spec[option_name] = directives.unchanged + opt_spec = cast(Dict[str, object], direc.option_spec) + opt_spec[option_name] = directives.unchanged log.debug(f"Registered {option_name} with {direc}") log.debug( - f"{direc}.option_spec now has keys: {list(direc.option_spec.keys())}" + f"{direc}.option_spec now has keys: {list(cast(Dict[str, object], direc.option_spec).keys())}" ) @@ -162,13 +172,17 @@ def tr_preparation(app: Sphinx, *args: object) -> None: setattr(app, "tr_types", {}) # Collects the configured test-report node types - app.tr_types[app.config.tr_file[0]] = app.config.tr_file[1:] - app.tr_types[app.config.tr_suite[0]] = app.config.tr_suite[1:] - app.tr_types[app.config.tr_case[0]] = app.config.tr_case[1:] + tr_types = cast(Dict[str, List[str]], getattr(app, "tr_types")) + tr_file = cast(List[str], app.config.tr_file) + tr_suite = cast(List[str], app.config.tr_suite) + tr_case = cast(List[str], app.config.tr_case) + tr_types[tr_file[0]] = tr_file[1:] + tr_types[tr_suite[0]] = tr_suite[1:] + tr_types[tr_case[0]] = tr_case[1:] - app.add_directive(app.config.tr_file[0], TestFileDirective) - app.add_directive(app.config.tr_suite[0], TestSuiteDirective) - app.add_directive(app.config.tr_case[0], TestCaseDirective) + cast(object, app.add_directive(tr_file[0], TestFileDirective)) + cast(object, app.add_directive(tr_suite[0], TestSuiteDirective)) + cast(object, app.add_directive(tr_case[0], TestCaseDirective)) def sphinx_needs_update(app: Sphinx, config: Config) -> None: @@ -180,31 +194,34 @@ def sphinx_needs_update(app: Sphinx, config: Config) -> None: # For details read # https://sphinx-needs.readthedocs.io/en/latest/api.html#sphinx_needs.api.configuration.add_extra_option - add_extra_option(app, "file") - add_extra_option(app, "suite") - add_extra_option(app, "case") - add_extra_option(app, "case_name") - add_extra_option(app, "case_parameter") - add_extra_option(app, "classname") - add_extra_option(app, "time") + cast(object, add_extra_option(app, "file")) + cast(object, add_extra_option(app, "suite")) + cast(object, add_extra_option(app, "case")) + cast(object, add_extra_option(app, "case_name")) + cast(object, add_extra_option(app, "case_parameter")) + cast(object, add_extra_option(app, "classname")) + cast(object, add_extra_option(app, "time")) - add_extra_option(app, "suites") - add_extra_option(app, "cases") + cast(object, add_extra_option(app, "suites")) + cast(object, add_extra_option(app, "cases")) - add_extra_option(app, "passed") - add_extra_option(app, "skipped") - add_extra_option(app, "failed") - add_extra_option(app, "errors") - add_extra_option(app, "result") # used by test cases only + cast(object, add_extra_option(app, "passed")) + cast(object, add_extra_option(app, "skipped")) + cast(object, add_extra_option(app, "failed")) + cast(object, add_extra_option(app, "errors")) + cast(object, add_extra_option(app, "result")) # used by test cases only # Extra dynamic functions # For details about usage read # https://sphinx-needs.readthedocs.io/en/latest/api.html#sphinx_needs.api.configuration.add_dynamic_function - add_dynamic_function(app, tr_link) + cast(object, add_dynamic_function(app, tr_link)) # Extra need types # For details about usage read # https://sphinx-needs.readthedocs.io/en/latest/api.html#sphinx_needs.api.configuration.add_need_type - add_need_type(app, *app.config.tr_file[1:]) - add_need_type(app, *app.config.tr_suite[1:]) - add_need_type(app, *app.config.tr_case[1:]) + tr_file = cast(List[str], app.config.tr_file) + tr_suite = cast(List[str], app.config.tr_suite) + tr_case = cast(List[str], app.config.tr_case) + cast(object, add_need_type(app, *tr_file[1:])) + cast(object, add_need_type(app, *tr_suite[1:])) + cast(object, add_need_type(app, *tr_case[1:])) From a01a9f39f8658774b7dd3e4460c40b3dddb0ed77 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 17:42:15 +0200 Subject: [PATCH 19/29] fix mypy error in test_case.py --- .../test_reports/directives/test_case.py | 122 +++++++++--------- 1 file changed, 64 insertions(+), 58 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_case.py b/sphinxcontrib/test_reports/directives/test_case.py index 44a38d9..bb0d28d 100644 --- a/sphinxcontrib/test_reports/directives/test_case.py +++ b/sphinxcontrib/test_reports/directives/test_case.py @@ -7,7 +7,7 @@ from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import TestReportInvalidOptionError -from typing import Dict, List, Optional, Match +from typing import Dict, List, Optional, Match, Tuple, cast class TestCase(nodes.General, nodes.Element): @@ -39,104 +39,110 @@ class TestCaseDirective(TestCommonDirective): def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) -> List[object]: + def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) -> List[nodes.Element]: self.prepare_basic_options() - self.load_test_file() - - if nested and suite_count >= 0: - # access n-th nested suite here - if self.results and isinstance(self.results, list) and len(self.results) > 0: - self.results = self.results[0]["testsuites"][suite_count] # type: ignore[index] - - suite_name = self.options.get("suite") + results = self.load_test_file() + suite_name = cast(Optional[str], self.options.get("suite")) if suite_name is None: raise TestReportInvalidOptionError("Suite not given!") - case_full_name = self.options.get("case") - class_name = self.options.get("classname") + case_full_name = cast(Optional[str], self.options.get("case")) + class_name = cast(Optional[str], self.options.get("classname")) if case_full_name is None and class_name is None: raise TestReportInvalidOptionError("Case or classname not given!") - suite = None - for suite_obj in self.results: - if nested: # nested testsuites - suite = self.results - break - - elif suite_obj["name"] == suite_name: - suite = suite_obj - break - - if suite is None: + # Typing aliases + TestsuiteDict = Dict[str, object] + TestcaseDict = Dict[str, object] + + # Gather candidate suites + candidate_suites: List[TestsuiteDict] = [] + if results is not None: + candidate_suites = cast(List[TestsuiteDict], results) + + # Handle nested selection if requested + selected_suite: Optional[TestsuiteDict] = None + if nested and suite_count >= 0 and candidate_suites: + root_suite = candidate_suites[0] + nested_suites = cast(List[TestsuiteDict], root_suite.get("testsuites", [])) + if 0 <= suite_count < len(nested_suites): + selected_suite = nested_suites[suite_count] + + # If not nested, search suite by name + if selected_suite is None: + for suite_obj in candidate_suites: + if str(suite_obj.get("name", "")) == suite_name: + selected_suite = suite_obj + break + + if selected_suite is None: raise TestReportInvalidOptionError( f"Suite {suite_name} not found in test file {self.test_file}" ) - case = None - - for case_obj in suite["testcases"]: - if case_obj["name"] == case_full_name and class_name is None: - case = case_obj + # Select testcase + testcases = cast(List[TestcaseDict], selected_suite.get("testcases", [])) + selected_case: Optional[TestcaseDict] = None + for case_obj in testcases: + name = str(case_obj.get("name", "")) + classname_val = str(case_obj.get("classname", "")) + if name == (case_full_name or "") and class_name is None: + selected_case = case_obj break - - elif (case_obj["classname"] == class_name and case_full_name is None) or ( - case_obj["name"] == case_full_name - and case_obj["classname"] == class_name + elif (classname_val == (class_name or "") and case_full_name is None) or ( + name == (case_full_name or "") and classname_val == (class_name or "") ): - case = case_obj + selected_case = case_obj break - elif nested and case_count >= 0: - # access correct case in list - case = suite["testcases"][case_count] - break + if selected_case is None and nested and case_count >= 0 and 0 <= case_count < len(testcases): + selected_case = testcases[case_count] - elif nested: - case = case_obj - break + if selected_case is None and nested and testcases: + selected_case = testcases[0] - if case is None: + if selected_case is None: raise TestReportInvalidOptionError( f"Case {case_full_name} with classname {class_name} not found in test file {self.test_file} " f"and testsuite {suite_name}" ) - result = case["result"] + result = str(selected_case.get("result", "")) content = self.test_content or "" - if case.get("text") is not None and isinstance(case["text"], str) and len(case["text"]) > 0: + if selected_case.get("text") is not None and isinstance(selected_case.get("text"), str) and len(cast(str, selected_case.get("text"))) > 0: content += """ **Text**:: {} -""".format("\n ".join([x.lstrip() for x in case["text"].split("\n")])) +""".format("\n ".join([x.lstrip() for x in cast(str, selected_case.get("text", "")).split("\n")])) - if case.get("message") is not None and isinstance(case["message"], str) and len(case["message"]) > 0: + if selected_case.get("message") is not None and isinstance(selected_case.get("message"), str) and len(cast(str, selected_case.get("message"))) > 0: content += """ **Message**:: {} -""".format("\n ".join([x.lstrip() for x in case["message"].split("\n")])) +""".format("\n ".join([x.lstrip() for x in cast(str, selected_case.get("message", "")).split("\n")])) - if case.get("system-out") is not None and isinstance(case["system-out"], str) and len(case["system-out"]) > 0: + if selected_case.get("system-out") is not None and isinstance(selected_case.get("system-out"), str) and len(cast(str, selected_case.get("system-out"))) > 0: content += """ **System-out**:: {} -""".format("\n ".join([x.lstrip() for x in case["system-out"].split("\n")])) +""".format("\n ".join([x.lstrip() for x in cast(str, selected_case.get("system-out", "")).split("\n")])) - time = case["time"] - style = "tr_" + str(case["result"]) + time = float(selected_case.get("time", 0.0)) + style = "tr_" + str(selected_case.get("result", "")) import re - groups: Optional[Match[str]] = re.match(r"^(?P[^\[]+)($|\[(?P.*)?\])", case["name"]) + groups: Optional[Match[str]] = re.match(r"^(?P[^\[]+)($|\[(?P.*)?\])", str(selected_case.get("name", ""))) if groups is not None: case_name = groups["name"] case_parameter = groups["param"] @@ -148,7 +154,7 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) case_parameter = "" # Set extra data, which is not part of the Sphinx-Test-Reports default options - for key, value in case.items(): + for key, value in selected_case.items(): if key == "id" and value not in ["", None]: self.test_id = str(value) elif key == "status" and value not in ["", None]: @@ -160,11 +166,11 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) if self.extra_options is not None: self.extra_options[key] = str(value) - docname = self.state.document.settings.env.docname + docname = cast(str, self.state.document.settings.env.docname) - main_section: List[object] = [] + main_section: List[nodes.Element] = [] # Merge all options including extra ones - main_section += add_need( + main_section += cast(List[nodes.Element], add_need( self.app, self.state, docname, @@ -178,7 +184,7 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) status=self.test_status, collapse=self.collapse, file=self.test_file_given, - suite=suite["name"], + suite=str(selected_suite.get("name", "")), case=case_full_name, case_name=case_name, case_parameter=case_parameter, @@ -186,8 +192,8 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) result=result, time=time, style=style, - **self.extra_options, - ) + **(self.extra_options or {}), + )) add_doc(self.env, docname) return main_section From 189b383eac608d3b952a74f68c6e25d137f78107 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 17:49:47 +0200 Subject: [PATCH 20/29] fix mypy errors in test_common.py --- .../test_reports/directives/test_common.py | 115 ++++++++++++------ 1 file changed, 75 insertions(+), 40 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_common.py b/sphinxcontrib/test_reports/directives/test_common.py index 0d0fd1d..025724b 100644 --- a/sphinxcontrib/test_reports/directives/test_common.py +++ b/sphinxcontrib/test_reports/directives/test_common.py @@ -6,7 +6,7 @@ import os import pathlib from importlib.metadata import version -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, List, Tuple, Mapping, MutableMapping, Union, Protocol, cast from docutils.parsers.rst import Directive from sphinx.util import logging @@ -16,7 +16,7 @@ SphinxError, TestReportFileNotSetError, ) -from sphinxcontrib.test_reports.jsonparser import JsonParser +from sphinxcontrib.test_reports.jsonparser import JsonParser, MappingEntry from sphinxcontrib.test_reports.junitparser import JUnitParser sn_major_version = int(version("sphinx-needs").split('.')[0]) @@ -30,20 +30,42 @@ # fmt: on +class _SphinxConfigProtocol(Protocol): + tr_rootdir: str + tr_json_mapping: Mapping[str, "MappingEntry"] # Provided by user config + needs_collapse_details: bool + + +class _SphinxAppProtocol(Protocol): + config: _SphinxConfigProtocol + tr_types: Mapping[str, Tuple[str, str]] + testreport_data: MutableMapping[str, List[Dict[str, object]]] + + +class _SphinxEnvProtocol(Protocol): + app: _SphinxAppProtocol + docname: str + + +# Re-export type name for readability in this file +MappingEntryType = MappingEntry + + class TestCommonDirective(Directive): """ Common directive, which provides some shared functions to "real" directives. """ - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - self.env: Any = self.state.document.settings.env - self.app: Any = self.env.app + self.env: _SphinxEnvProtocol = cast(_SphinxEnvProtocol, self.state.document.settings.env) + self.app: _SphinxAppProtocol = self.env.app if not hasattr(self.app, "testreport_data"): - self.app.testreport_data: Dict[str, Any] = {} + empty_store: Dict[str, List[Dict[str, object]]] = {} + setattr(self.app, "testreport_data", empty_store) self.test_file: Optional[str] = None - self.results: Optional[Any] = None + self.results: Optional[List[Dict[str, object]]] = None self.docname: Optional[str] = None self.test_name: Optional[str] = None self.test_id: Optional[str] = None @@ -52,23 +74,24 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.test_links: Optional[str] = None self.test_tags: Optional[str] = None self.test_status: Optional[str] = None - self.collapse: Optional[Any] = None + self.collapse: bool = True self.need_type: Optional[str] = None - self.extra_options: Optional[Dict[str, Any]] = None + self.extra_options: Optional[Dict[str, object]] = None self.log = logging.getLogger(__name__) def collect_extra_options(self) -> None: """Collect any extra options and their values that were specified in the directive""" - tr_extra_options = getattr(self.app.config, "tr_extra_options", []) - self.extra_options: Dict[str, Any] = {} + tr_extra_options = cast(Optional[List[str]], getattr(self.app.config, "tr_extra_options", None)) + extra: Dict[str, object] = {} if tr_extra_options: for option_name in tr_extra_options: if option_name in self.options: - self.extra_options[option_name] = self.options[option_name] + extra[option_name] = self.options[option_name] + self.extra_options = extra - def load_test_file(self) -> Optional[Any]: + def load_test_file(self) -> Optional[List[Dict[str, object]]]: """ Loads the defined test_file under self.test_file. @@ -91,15 +114,19 @@ def load_test_file(self) -> Optional[Any]: ) return None - if self.test_file not in self.app.testreport_data.keys(): + testreport_data = self.app.testreport_data + if self.test_file not in testreport_data.keys(): + parser: Union[JsonParser, JUnitParser] if os.path.splitext(self.test_file)[1] == ".json": - mapping = list(self.app.config.tr_json_mapping.values())[0] + json_mapping_all = self.app.config.tr_json_mapping + mapping_values = list(json_mapping_all.values()) + mapping: MappingEntryType = mapping_values[0] if mapping_values else {"testcase": {}, "testsuite": {}} parser = JsonParser(self.test_file, json_mapping=mapping) else: parser = JUnitParser(self.test_file) - self.app.testreport_data[self.test_file] = parser.parse() + testreport_data[self.test_file] = parser.parse() - self.results = self.app.testreport_data[self.test_file] + self.results = testreport_data[self.test_file] return self.results def prepare_basic_options(self) -> None: @@ -107,52 +134,60 @@ def prepare_basic_options(self) -> None: Reads and checks the needed basic data like name, id, links, status, ... :return: None """ - self.docname = self.state.document.settings.env.docname + self.docname = cast(str, self.state.document.settings.env.docname) self.test_name = self.arguments[0] self.test_content = "\n".join(self.content) if self.name != "test-report": self.need_type = self.app.tr_types[self.name][0] if sn_major_version >= 4: - hashed_id = _make_hashed_id( - self.need_type, - self.test_name, - self.test_content, - NeedsSphinxConfig(self.app.config), + hashed_id = cast( + str, + _make_hashed_id( + self.need_type, + self.test_name, + self.test_content, + NeedsSphinxConfig(self.app.config), + ), ) else: # Sphinx-Needs < 4 - hashed_id = make_hashed_id( - self.app, self.need_type, self.test_name, self.test_content + hashed_id = cast( + str, + make_hashed_id( + self.app, self.need_type, self.test_name, self.test_content + ), ) - self.test_id = self.options.get( - "id", - hashed_id, - ) + opt_id = self.options.get("id") + self.test_id = str(opt_id) if opt_id is not None else hashed_id else: - self.test_id = self.options.get("id") + opt_id = self.options.get("id") + self.test_id = str(opt_id) if opt_id is not None else None if self.test_id is None: raise SphinxError("ID must be set for test-report.") - self.test_file = self.options.get("file") - self.test_file_given = self.test_file[:] + self.test_file = cast(Optional[str], self.options.get("file")) + self.test_file_given = str(self.test_file) if self.test_file is not None else None - self.test_links = self.options.get("links", "") - self.test_tags = self.options.get("tags", "") - self.test_status = self.options.get("status") + self.test_links = cast(str, self.options.get("links", "")) + self.test_tags = cast(str, self.options.get("tags", "")) + self.test_status = cast(Optional[str], self.options.get("status")) - self.collapse = str(self.options.get("collapse", "")) + collapse_raw: object = self.options.get("collapse", "") - if isinstance(self.collapse, str) and len(self.collapse) > 0: - if self.collapse.upper() in ["TRUE", 1, "YES"]: + if isinstance(collapse_raw, str) and len(collapse_raw) > 0: + value = collapse_raw.strip().upper() + if value in ("TRUE", "YES", "1"): self.collapse = True - elif self.collapse.upper() in ["FALSE", 0, "NO"]: + elif value in ("FALSE", "NO", "0"): self.collapse = False else: raise Exception("collapse attribute must be true or false") + elif isinstance(collapse_raw, bool): + self.collapse = collapse_raw else: - self.collapse = getattr(self.app.config, "needs_collapse_details", True) + self.collapse = bool(self.app.config.needs_collapse_details) # Also collect any extra options while we're at it self.collect_extra_options() From dd85b1142f76305c769134f84b94ec433fb30744 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 17:56:57 +0200 Subject: [PATCH 21/29] fix mypy errors test_env.py --- .../test_reports/directives/test_env.py | 93 ++++++++++++------- 1 file changed, 60 insertions(+), 33 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_env.py b/sphinxcontrib/test_reports/directives/test_env.py index 3af883a..385b183 100644 --- a/sphinxcontrib/test_reports/directives/test_env.py +++ b/sphinxcontrib/test_reports/directives/test_env.py @@ -1,22 +1,43 @@ +from __future__ import annotations + import copy import json import os +from typing import List, Optional, Tuple, Dict, Iterable, Protocol, cast import sphinx from docutils import nodes from docutils.parsers.rst import Directive, directives from packaging.version import Version -from typing import Any, List, Optional, Tuple +from sphinx.environment import BuildEnvironment + +# ---------- Typing ---------- + +class LoggerProtocol(Protocol): + def debug(self, msg: str) -> object: ... + def info(self, msg: str) -> object: ... + def warning(self, msg: str) -> object: ... + def error(self, msg: str) -> object: ... + +class _AppConfigProtocol(Protocol): + tr_rootdir: str + +class _AppProtocol(Protocol): + config: _AppConfigProtocol + +# ---------- Logger ---------- sphinx_version = sphinx.__version__ +logger: LoggerProtocol if Version(sphinx_version) >= Version("1.6"): - from sphinx.util import logging + from sphinx.util import logging as sphinx_logging + logger = cast(LoggerProtocol, sphinx_logging.getLogger(__name__)) else: - import logging - - logging.basicConfig() -logger = logging.getLogger(__name__) + import logging as std_logging + std_logging.basicConfig() + logger = cast(LoggerProtocol, std_logging.getLogger(__name__)) +# ---------- Nodes & Directive ---------- class EnvReport(nodes.General, nodes.Element): pass @@ -38,37 +59,43 @@ class EnvReportDirective(Directive): final_argument_whitespace = True - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.data_option: Optional[str] = self.options.get("data") - self.environments: Optional[str] = self.options.get("env") + header: Tuple[str, str] = ("Variable", "Data") + colwidths: Tuple[int, int] = (1, 1) + + # Initialized in run() + req_env_list: Optional[List[str]] + data_option_list: Optional[List[str]] + + def run(self) -> List[nodes.Node]: + # Prepare options + data_option = cast(Optional[str], self.options.get("data")) + environments = cast(Optional[str], self.options.get("env")) - if self.environments is not None: - self.req_env_list_cpy: List[str] = self.environments.split(",") - self.req_env_list: Optional[List[str]] = [] - for element in self.req_env_list_cpy: + if environments is not None: + req_env_list_cpy: List[str] = environments.split(",") + self.req_env_list = [] + for element in req_env_list_cpy: if len(element) != 0: self.req_env_list.append(element.lstrip().rstrip()) else: self.req_env_list = None - if self.data_option is not None: - self.data_option_list_cpy: List[str] = self.data_option.split(",") - self.data_option_list: Optional[List[str]] = [] - for element in self.data_option_list_cpy: + if data_option is not None: + data_option_list_cpy: List[str] = data_option.split(",") + self.data_option_list = [] + for element in data_option_list_cpy: if len(element) != 0: self.data_option_list.append(element.rstrip().lstrip()) else: self.data_option_list = None - self.header: Tuple[str, str] = ("Variable", "Data") - self.colwidths: Tuple[int, int] = (1, 1) - - def run(self) -> List[Any]: - env = self.state.document.settings.env + env = cast(BuildEnvironment, self.state.document.settings.env) json_path = self.arguments[0] - root_path = env.app.config.tr_rootdir + app_typed: _AppProtocol = cast(_AppProtocol, env.app) + cfg: _AppConfigProtocol = app_typed.config + root_path = cfg.tr_rootdir # str + if not os.path.isabs(json_path): json_path = os.path.join(root_path, json_path) @@ -77,7 +104,7 @@ def run(self) -> List[Any]: with open(json_path) as fp_json: try: - results = json.load(fp_json) + results: Dict[str, Dict[str, object]] = json.load(fp_json) except ValueError as exc: raise InvalidJsonFile( "The given file {} is not a valid JSON".format( @@ -96,7 +123,7 @@ def run(self) -> List[Any]: del not_present_env # Construction idea taken from http://agateau.com/2015/docutils-snippets/ - main_section: List[Any] = [] + main_section: List[nodes.Node] = [] if self.req_env_list is None and "raw" not in self.options: for enviro in results: @@ -130,7 +157,7 @@ def run(self) -> List[Any]: code_block = nodes.literal_block(results_string, results_string) code_block["language"] = "json" section += code_block # nodes.literal_block(results, results) - main_section += section # nodes.literal_block(enviro, results[enviro]) + main_section.append(section) # nodes.literal_block(enviro, results[enviro]) del temp_dict2 elif "raw" in self.options and self.req_env_list is not None: @@ -144,7 +171,7 @@ def run(self) -> List[Any]: del temp_dict2[enviro][opt] # option check - for opt in self.data_option_list: + for opt in self.data_option_list or []: if opt not in temp_dict2[enviro]: logger.warning( f"option '{opt}' is not present in '{enviro}' environment file" @@ -158,13 +185,13 @@ def run(self) -> List[Any]: code_block = nodes.literal_block(results_string, results_string) code_block["language"] = "json" section += code_block - main_section += section + main_section.append(section) del temp_dict2 return main_section - def _crete_table_b(self, enviro: str, results: Any) -> List[Any]: - main_section: List[Any] = [] + def _crete_table_b(self, enviro: str, results: Dict[str, Dict[str, object]]) -> List[nodes.Node]: + main_section: List[nodes.Node] = [] section = nodes.section() section += nodes.title(text=enviro) @@ -204,7 +231,7 @@ def _crete_table_b(self, enviro: str, results: Any) -> List[Any]: return main_section - def _create_rows(self, row_cells: Any) -> Any: + def _create_rows(self, row_cells: Iterable[object]) -> nodes.row: row = nodes.row() for cell in row_cells: entry = nodes.entry() @@ -215,7 +242,7 @@ def _create_rows(self, row_cells: Any) -> Any: code_block["language"] = "json" entry += code_block else: - entry += nodes.paragraph(text=cell) + entry += nodes.paragraph(text=cast(str, cell)) return row From d9fd750bee76bcc009e0e31a4f06c3a2e68850d4 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 18:00:14 +0200 Subject: [PATCH 22/29] fix mypy errors test_file.py --- .../test_reports/directives/test_file.py | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_file.py b/sphinxcontrib/test_reports/directives/test_file.py index a66f174..ae94f7e 100644 --- a/sphinxcontrib/test_reports/directives/test_file.py +++ b/sphinxcontrib/test_reports/directives/test_file.py @@ -9,7 +9,7 @@ from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import TestReportIncompleteConfigurationError -from typing import Dict, List, Optional, Any +from typing import Dict, List, Optional, cast class TestFile(nodes.General, nodes.Element): @@ -41,13 +41,13 @@ def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) self.suite_ids: Dict[str, str] = {} - def run(self) -> List[object]: + def run(self) -> List[nodes.Element]: self.prepare_basic_options() results: Optional[List[Dict[str, object]]] = self.load_test_file() # Error handling, if file not found if results is None: - main_section: List[object] = [] + main_section: List[nodes.Element] = [] content = nodes.error() para = nodes.paragraph() text_string = f"Test file not found: {self.test_file}" @@ -57,16 +57,31 @@ def run(self) -> List[object]: main_section.append(content) return main_section - suites = len(self.results) if self.results is not None else 0 - cases = sum(int(x["tests"]) for x in self.results) if self.results is not None else 0 - passed = sum(x["passed"] for x in self.results) if self.results is not None else 0 - skipped = sum(x["skips"] for x in self.results) if self.results is not None else 0 - errors = sum(x["errors"] for x in self.results) if self.results is not None else 0 - failed = sum(x["failures"] for x in self.results) if self.results is not None else 0 - - main_section: List[object] = [] - docname = self.state.document.settings.env.docname - main_section += add_need( + def as_int(val: object) -> int: + if isinstance(val, bool): + return int(val) + if isinstance(val, int): + return val + if isinstance(val, float): + return int(val) + if isinstance(val, str): + try: + return int(val) + except ValueError: + return 0 + return 0 + + results_list = cast(List[Dict[str, object]], results) + suites = len(results_list) + cases = sum(as_int(x.get("tests", 0)) for x in results_list) + passed = sum(as_int(x.get("passed", 0)) for x in results_list) + skipped = sum(as_int(x.get("skips", 0)) for x in results_list) + errors = sum(as_int(x.get("errors", 0)) for x in results_list) + failed = sum(as_int(x.get("failures", 0)) for x in results_list) + + main_section = [] # type: List[nodes.Element] + docname = cast(str, self.state.document.settings.env.docname) + main_section += cast(List[nodes.Element], add_need( self.app, self.state, docname, @@ -86,8 +101,8 @@ def run(self) -> List[object]: skipped=skipped, failed=failed, errors=errors, - **self.extra_options, - ) + **(self.extra_options or {}), + )) if ( "auto_cases" in self.options.keys() @@ -98,25 +113,26 @@ def run(self) -> List[object]: "auto_suites for test-file directives." ) - if "auto_suites" in self.options.keys() and self.results is not None: - for suite in self.results: + if "auto_suites" in self.options.keys() and results_list is not None: + for suite in results_list: + suite_name = str(suite.get("name", "")) suite_id = self.test_id or "" suite_id += ( "_" - + hashlib.sha1(suite["name"].encode("UTF-8")) + + hashlib.sha1(suite_name.encode("UTF-8")) .hexdigest() - .upper()[: self.app.config.tr_suite_id_length] + .upper()[: cast(int, self.app.config.tr_suite_id_length)] ) if suite_id not in self.suite_ids: - self.suite_ids[suite_id] = suite["name"] + self.suite_ids[suite_id] = suite_name else: raise Exception( - f"Suite ID {suite_id} already exists by {self.suite_ids[suite_id]} ({suite['name']})" + f"Suite ID {suite_id} already exists by {self.suite_ids[suite_id]} ({suite_name})" ) options = self.options.copy() - options["suite"] = suite["name"] + options["suite"] = suite_name options["id"] = suite_id if "links" not in options: @@ -124,10 +140,10 @@ def run(self) -> List[object]: elif self.test_id and self.test_id not in options["links"]: options["links"] = options["links"] + ";" + self.test_id - arguments = [suite["name"]] + arguments = [suite_name] suite_directive = ( sphinxcontrib.test_reports.directives.test_suite.TestSuiteDirective( - self.app.config.tr_suite[0], + cast(List[str], self.app.config.tr_suite)[0], arguments, options, "", @@ -139,7 +155,7 @@ def run(self) -> List[object]: ) ) - main_section += suite_directive.run() + main_section += cast(List[nodes.Element], suite_directive.run()) add_doc(self.env, docname) From 689bed71d4031e3b7b96bc49a599cfb4662bd541 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 18:03:18 +0200 Subject: [PATCH 23/29] fix mypy errors test_report.py --- .../test_reports/directives/test_report.py | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_report.py b/sphinxcontrib/test_reports/directives/test_report.py index 0f7f67d..1c3a098 100644 --- a/sphinxcontrib/test_reports/directives/test_report.py +++ b/sphinxcontrib/test_reports/directives/test_report.py @@ -3,7 +3,7 @@ from docutils import nodes from docutils.parsers.rst import directives -from typing import Any, List +from typing import List, Dict, Optional, Protocol, cast from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import InvalidConfigurationError @@ -34,29 +34,36 @@ class TestReportDirective(TestCommonDirective): final_argument_whitespace = True - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - def run(self) -> List[Any]: + class _AppConfigTR(Protocol): + tr_report_template: str + tr_import_encoding: str + tr_file: List[str] + tr_suite: List[str] + tr_case: List[str] + + def run(self) -> List[nodes.Element]: self.prepare_basic_options() self.load_test_file() # if user provides a custom template, use it - tr_template = pathlib.Path(self.app.config.tr_report_template) + cfg = cast(TestReportDirective._AppConfigTR, self.app.config) + tr_template = pathlib.Path(cfg.tr_report_template) if tr_template.is_absolute(): template_path = tr_template else: - template_path = pathlib.Path(self.app.confdir) / tr_template + app_confdir = cast(str, getattr(self.app, "confdir")) + template_path = pathlib.Path(app_confdir) / tr_template if not template_path.is_file(): raise InvalidConfigurationError( f"could not find a template file with name {template_path} in conf.py directory" ) - with template_path.open( - encoding=self.app.config.tr_import_encoding - ) as template_file: + with template_path.open(encoding=cfg.tr_import_encoding) as template_file: template = "".join(template_file.readlines()) if self.test_links is not None and len(self.test_links) > 0: @@ -64,26 +71,25 @@ def run(self) -> List[Any]: else: links_string = "" - template_data = { - "file": self.test_file, - "id": self.test_id, - "file_type": self.app.config.tr_file[0], - "suite_need": self.app.config.tr_suite[1], - "case_need": self.app.config.tr_case[1], - "tags": ( - ";".join([self.test_tags, self.test_id]) - if len(self.test_tags) > 0 - else self.test_id - ), + tags_str = self.test_tags or "" + id_str = self.test_id or "" + tags_value = ";".join([tags_str, id_str]) if len(tags_str) > 0 else id_str + + template_data: Dict[str, object] = { + "file": self.test_file or "", + "id": id_str, + "file_type": cast(List[str], cfg.tr_file)[0], + "suite_need": cast(List[str], cfg.tr_suite)[1], + "case_need": cast(List[str], cfg.tr_case)[1], + "tags": tags_value, "links_string": links_string, - "title": self.test_name, + "title": self.test_name or "", "content": self.content, "template_path": str(template_path), } template_ready = template.format(**template_data) - self.state_machine.insert_input( - template_ready.split("\n"), self.state_machine.document.attributes["source"] - ) + src = cast(str, self.state_machine.document.attributes["source"]) + self.state_machine.insert_input(template_ready.split("\n"), src) - return [] + return cast(List[nodes.Element], []) From 95621b37123b0dd9b5d992bce676bf2246982dbf Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 18:04:31 +0200 Subject: [PATCH 24/29] fix one mypy error --- sphinxcontrib/test_reports/directives/test_results.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sphinxcontrib/test_reports/directives/test_results.py b/sphinxcontrib/test_reports/directives/test_results.py index 8ef1cd6..1d1a313 100644 --- a/sphinxcontrib/test_reports/directives/test_results.py +++ b/sphinxcontrib/test_reports/directives/test_results.py @@ -11,6 +11,8 @@ TestcaseDict = Dict[str, Union[str, int, float]] TestsuiteDict = Dict[str, Union[str, int, float, List[TestcaseDict], List["TestsuiteDict"]]] +class TestResults(nodes.General, nodes.Element): + pass class TestResultsDirective(Directive): """ From 1a491f64c42c46680d7288b1263bba90347144b8 Mon Sep 17 00:00:00 2001 From: Korbinian weber Date: Sat, 30 Aug 2025 18:15:50 +0200 Subject: [PATCH 25/29] fix mypy errors test_suite.py --- .../test_reports/directives/test_suite.py | 278 ++++++++++-------- 1 file changed, 160 insertions(+), 118 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_suite.py b/sphinxcontrib/test_reports/directives/test_suite.py index 9a69734..d7b1282 100644 --- a/sphinxcontrib/test_reports/directives/test_suite.py +++ b/sphinxcontrib/test_reports/directives/test_suite.py @@ -1,30 +1,60 @@ -import hashlib +from __future__ import annotations +import hashlib +from typing import Callable, Dict, List, Optional, TypedDict, cast, Protocol, runtime_checkable, ClassVar, Tuple from docutils import nodes +from docutils.nodes import Node from docutils.parsers.rst import directives from sphinx_needs.api import add_need from sphinx_needs.utils import add_doc - -import sphinxcontrib.test_reports.directives.test_case +import sphinxcontrib.test_reports.directives.test_case as test_case_mod from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import TestReportInvalidOptionError -from typing import Dict, List, Optional + +# --------- TypedDicts for parser results ------------ + +class TestCaseDict(TypedDict): + name: str + classname: str + +class TestSuiteDict(TypedDict, total=False): + name: str + tests: int + passed: int + skips: int + errors: int + failures: int + testcases: List[TestCaseDict] + testsuite_nested: List["TestSuiteDict"] + testsuites: List["TestSuiteDict"] + + +# --------- Protocol for required config fields --------- + +@runtime_checkable +class _SuiteConfigProtocol(Protocol): + tr_suite_id_length: int + tr_case_id_length: int + tr_suite: Tuple[str, ...] + tr_case: Tuple[str, ...] +# --------- Node class --------- + class TestSuite(nodes.General, nodes.Element): pass class TestSuiteDirective(TestCommonDirective): """ - Directive for showing test suites. + Directive for rendering test suites. """ - has_content = True - required_arguments = 1 - optional_arguments = 0 - option_spec = { + has_content: ClassVar[bool] = True + required_arguments: ClassVar[int] = 1 + optional_arguments: ClassVar[int] = 0 + option_spec: ClassVar[Dict[str, Callable[[str], object]]] = { "id": directives.unchanged_required, "status": directives.unchanged_required, "tags": directives.unchanged_required, @@ -33,50 +63,69 @@ class TestSuiteDirective(TestCommonDirective): "file": directives.unchanged_required, "suite": directives.unchanged_required, } + final_argument_whitespace: ClassVar[bool] = True - final_argument_whitespace = True + _nested_flag: ClassVar[bool] = False + _nested_index: ClassVar[int] = -1 def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) self.case_ids: List[str] = [] - def run(self, nested: bool = False, count: int = -1) -> List[object]: - self.prepare_basic_options() - self.load_test_file() + def _ensure_results_list(self) -> List[TestSuiteDict]: + """Ensure that self.results is a list of TestSuiteDict.""" + if self.results is None: + self.load_test_file() + results_list = cast(List[Dict[str, object]], self.results) + return cast(List[TestSuiteDict], results_list) - if nested: - # access n-th nested suite here - self.results = self.results[0]["testsuite_nested"] # type: ignore[index] + def _get_cfg_suite(self) -> _SuiteConfigProtocol: + """Cast app config to the extended Suite Protocol.""" + return cast(_SuiteConfigProtocol, self.app.config) - suite_name = self.options.get("suite") + def run(self, nested: bool = False, count: int = -1) -> List[Node]: + self.prepare_basic_options() + results: List[TestSuiteDict] = self._ensure_results_list() - if suite_name is None: + # If nested, access the first element's nested suites + if nested: + if not results: + raise TestReportInvalidOptionError("No suites available for nested access.") + results = results[0].get("testsuite_nested", []) + + # Get target suite by name from options + suite_name_obj = self.options.get("suite") + suite_name = str(suite_name_obj) if isinstance(suite_name_obj, (str, bytes)) else None + if not suite_name: raise TestReportInvalidOptionError("Suite not given!") - suite = None - for suite_obj in self.results: - if suite_obj["name"] == suite_name: + suite: Optional[TestSuiteDict] = None + for suite_obj in results: + if suite_obj.get("name") == suite_name: suite = suite_obj break - elif nested: # access correct nested testsuite here - suite = self.results[count] - break + # If nested, select by index if not found by name + if suite is None and nested and 0 <= count < len(results): + suite = results[count] if suite is None: raise TestReportInvalidOptionError( f"Suite {suite_name} not found in test file {self.test_file}" ) - cases = suite["tests"] if "tests" in suite else 0 - passed = suite["passed"] if "passed" in suite else 0 - skipped = suite["skips"] if "skips" in suite else 0 - errors = suite["errors"] if "errors" in suite else 0 - failed = suite["failures"] if "failures" in suite else 0 + # Get counts safely with default 0 + cases_count = int(suite.get("tests", 0)) + passed = int(suite.get("passed", 0)) + skipped = int(suite.get("skips", 0)) + errors = int(suite.get("errors", 0)) + failed = int(suite.get("failures", 0)) + + main_section: List[Node] = [] + docname = cast(str, self.state.document.settings.env.docname) - main_section: List[object] = [] - docname = self.state.document.settings.env.docname - main_section += add_need( + # Create the need node for this suite + need_nodes = cast(List[Node], add_need( self.app, self.state, docname, @@ -90,116 +139,109 @@ def run(self, nested: bool = False, count: int = -1) -> List[object]: status=self.test_status, collapse=self.collapse, file=self.test_file_given, - suite=suite["name"], - cases=cases, + suite=suite.get("name", ""), + cases=cases_count, passed=passed, skipped=skipped, failed=failed, errors=errors, - **self.extra_options, - ) - - # TODO double nested logic - # nested testsuite present, if testcases are present -> reached most inner testsuite - access_count = 0 - if "testcases" in suite_obj and isinstance(suite_obj["testcases"], list) and len(suite_obj["testcases"]) == 0: - for nested_suite in suite_obj.get("testsuite_nested", []): - suite_id = (self.test_id or "") - suite_id += ( - "_" - + hashlib.sha1(nested_suite["name"].encode("UTF-8")) - .hexdigest() - .upper()[: self.app.config.tr_suite_id_length] - ) - - options = self.options.copy() - options["suite"] = nested_suite["name"] - options["id"] = suite_id - - if "links" not in options: - options["links"] = self.test_id or "" - elif self.test_id and self.test_id not in options["links"]: - options["links"] = options["links"] + ";" + self.test_id + **(self.extra_options or {}), + )) + main_section += need_nodes + + # --- Handle nested suites if current suite has no testcases --- + testcases_list = suite.get("testcases", []) + if not testcases_list: + nested_suites = suite.get("testsuite_nested", []) + if isinstance(nested_suites, list) and nested_suites: + access_count = 0 + for nested_suite in nested_suites: + cfg = self._get_cfg_suite() + base_id = self.test_id or "" + derived = ( + base_id + + "_" + + hashlib.sha1(nested_suite.get("name", "").encode("UTF-8")) + .hexdigest() + .upper()[: cfg.tr_suite_id_length] + ) - arguments = [nested_suite["name"]] - suite_directive = ( - sphinxcontrib.test_reports.directives.test_suite.TestSuiteDirective( - self.app.config.tr_suite[0], + nested_options: Dict[str, object] = dict(self.options) + nested_options["suite"] = nested_suite.get("name", "") + nested_options["id"] = derived + + # Maintain links + if "links" not in nested_options: + nested_options["links"] = self.test_id or "" + elif isinstance(nested_options["links"], str) and self.test_id: + if self.test_id not in nested_options["links"]: + nested_options["links"] += ";" + self.test_id + + arguments = [nested_suite.get("name", "")] + cfg_suite_tuple = self._get_cfg_suite().tr_suite + suite_directive = TestSuiteDirective( + cfg_suite_tuple[0], arguments, - options, + nested_options, "", - self.lineno, # no content + self.lineno, self.content_offset, self.block_text, self.state, self.state_machine, ) - ) - is_nested = len(suite_obj.get("testsuites", [])) > 0 + # Run nested suite directive + main_section += cast(List[Node], suite_directive.run(nested=True, count=access_count)) + access_count += 1 - # create suite_directive for each nested suite, directive appends content in html files - # access_count keeps track of which nested testsuite to access in the directive - main_section += suite_directive.run(nested=True, count=access_count) - access_count += 1 - - # suite has testcases - if "auto_cases" in self.options.keys() and "testcases" in suite and isinstance(suite["testcases"], list) and len(suite["testcases"]) > 0: + # --- Automatically create testcase nodes if configured --- + if "auto_cases" in self.options.keys() and isinstance(testcases_list, list) and testcases_list: case_count = 0 + for case in testcases_list: + cfg = self._get_cfg_suite() + base_id = self.test_id or "" + compound = (case.get("classname", "") + "\x00" + case.get("name", "")).encode("UTF-8") + case_id = base_id + "_" + hashlib.sha1(compound).hexdigest().upper()[: cfg.tr_case_id_length] - for case in suite["testcases"]: - case_id = (self.test_id or "") - case_id += ( - "_" - + hashlib.sha1( - case["classname"].encode("UTF-8") + case["name"].encode("UTF-8") - ) - .hexdigest() - .upper()[: self.app.config.tr_case_id_length] - ) - - if case_id not in self.case_ids: - self.case_ids.append(case_id) - else: + if case_id in self.case_ids: raise Exception(f"Case ID exists: {case_id}") - - # We need to copy self.options, otherwise it gets updated and sets same values - # for all testsuites. - options = self.options.copy() - - options["case"] = case["name"] - options["classname"] = case["classname"] - options["id"] = case_id - - if "links" not in options: - options["links"] = self.test_id or "" - elif self.test_id and self.test_id not in options["links"]: - options["links"] = options["links"] + ";" + self.test_id - - arguments = [case["name"]] - case_directive = ( - sphinxcontrib.test_reports.directives.test_case.TestCaseDirective( - self.app.config.tr_case[0], - arguments, - options, - "", - self.lineno, # no content - self.content_offset, - self.block_text, - self.state, - self.state_machine, - ) + self.case_ids.append(case_id) + + case_options: Dict[str, object] = dict(self.options) + case_options["case"] = case.get("name", "") + case_options["classname"] = case.get("classname", "") + case_options["id"] = case_id + + if "links" not in case_options: + case_options["links"] = self.test_id or "" + elif isinstance(case_options["links"], str) and self.test_id: + if self.test_id not in case_options["links"]: + case_options["links"] += ";" + self.test_id + + arguments = [case.get("name", "")] + cfg_case_tuple = self._get_cfg_suite().tr_case + case_directive = test_case_mod.TestCaseDirective( + cfg_case_tuple[0], + arguments, + case_options, + "", + self.lineno, + self.content_offset, + self.block_text, + self.state, + self.state_machine, ) - is_nested = ("testsuite_nested" in suite_obj and isinstance(suite_obj["testsuite_nested"], list) and len(suite_obj["testsuite_nested"]) > 0) or nested + is_nested = (("testsuite_nested" in suite and isinstance(suite.get("testsuite_nested"), list) and len(cast(List[object], suite.get("testsuite_nested"))) > 0) + or nested) - # depending if nested or not, runs case directive to add content to testcases - # count is for correct suite access, if multiple present, case_count is for correct case access - main_section += case_directive.run(is_nested, count, case_count) + main_section += cast(List[Node], case_directive.run(is_nested, count, case_count)) if is_nested: case_count += 1 + # Register document add_doc(self.env, docname) return main_section From bb6bf4ccf7e4b3a3642cfea33c1a75b6915d74cf Mon Sep 17 00:00:00 2001 From: korbi-web-215 <149482188+korbi-web-215@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:40:34 +0200 Subject: [PATCH 26/29] rebasen vom master --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yaml | 50 +++++++++++++++++++ README.rst | 2 +- docs/conf.py | 4 +- docs/configuration.rst | 2 +- noxfile.py | 2 +- pyproject.toml | 3 +- .../test_reports/directives/test_case.py | 19 ++++++- sphinxcontrib/test_reports/environment.py | 8 +-- sphinxcontrib/test_reports/test_reports.py | 43 ++++++++++++---- tests/test_custom_template.py | 2 +- 11 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 58f773d..4995643 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ jobs: os: [ubuntu-latest] python-version: ["3.10", "3.12"] # No "3.10", as nose seem to have problems with it sphinx-version: ["5.0", "8.1.3"] - sphinx_needs-version: ["2.1", "4.2"] + sphinx_needs-version: ["2.1", "4.2", "5.1"] steps: - uses: actions/checkout@v2 - name: Set Up Python ${{ matrix.python-version }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..d3b9f4b --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,50 @@ +name: Release +on: + push: + tags: + - '[0-9].[0-9]+.[0-9]+' + +permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + contents: read + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m pip install build --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + needs: + - build + runs-on: ubuntu-latest + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/README.rst b/README.rst index cb800fa..b5c8ed1 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ Sphinx-Test-Reports =================== -.. image:: docs/_static/sphinx-test-reports-logo.png +.. image:: https://raw.githubusercontent.com/useblocks/sphinx-test-reports/master/docs/_static/sphinx-test-reports-logo.png Sphinx-Test-Reports shows test results of your unit tests inside your sphinx documentation. diff --git a/docs/conf.py b/docs/conf.py index 6ec7480..f8ce4ed 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,9 +29,9 @@ author = "team useblocks" # The short X.Y version -version = "1.2" +version = "1.3" # The full version, including alpha/beta/rc tags -release = "1.2.0" +release = "1.3.1" needs_id_regex = ".*" needs_css = "dark.css" diff --git a/docs/configuration.rst b/docs/configuration.rst index 68eb026..d3a2676 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -256,4 +256,4 @@ An example of a JSON file, which supports the below configuration, can be seen i "my-option": (["my_opt"], "default"), } } - } + } \ No newline at end of file diff --git a/noxfile.py b/noxfile.py index 324f185..c9b62af 100644 --- a/noxfile.py +++ b/noxfile.py @@ -3,7 +3,7 @@ PYTHON_VERSIONS = ["3.10", "3.12"] SPHINX_VERSIONS = ["5.0", "7.2.5", "8.1.3"] -SPHINX_NEEDS_VERSIONS = ["2.1", "4.2"] +SPHINX_NEEDS_VERSIONS = ["2.1", "4.2", "5.1", "6.0.1"] def run_tests(session, sphinx, sphinx_needs): diff --git a/pyproject.toml b/pyproject.toml index 96d09c3..9602c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "sphinx-test-reports" -version = "1.2.0" +version = "1.3.1" description = "Sphinx extension for showing test results and test environment information inside sphinx documentations" readme = "README.rst" license = { text = "MIT" } @@ -40,7 +40,6 @@ docs = [ "sphinx-design", "sphinx-immaterial", "sphinx-needs>=4", - "sphinx-test-reports>=0.3.3", "sphinx==7", "sphinxcontrib-plantuml", ] diff --git a/sphinxcontrib/test_reports/directives/test_case.py b/sphinxcontrib/test_reports/directives/test_case.py index bb0d28d..bb47a11 100644 --- a/sphinxcontrib/test_reports/directives/test_case.py +++ b/sphinxcontrib/test_reports/directives/test_case.py @@ -138,6 +138,23 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) """.format("\n ".join([x.lstrip() for x in cast(str, selected_case.get("system-out", "")).split("\n")])) time = float(selected_case.get("time", 0.0)) + + # Ensure time is a string, SN 6.0.0 requires to be in one specific type + # and it is set to string for backwards compatibility + if isinstance(time, (int, float)): + # Keep as numeric seconds (decimal format) + time = float(time) if time >= 0 else 0.0 + elif time is None: + time = 0.0 + else: + # Try to parse string to float, fallback to 0.0 + try: + time = float(time) + except (ValueError, TypeError): + time = 0.0 + time_str = str(time) + + # If time is already a string or None, keep it as is style = "tr_" + str(selected_case.get("result", "")) import re @@ -190,7 +207,7 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) case_parameter=case_parameter, classname=class_name, result=result, - time=time, + time=time_str, style=style, **(self.extra_options or {}), )) diff --git a/sphinxcontrib/test_reports/environment.py b/sphinxcontrib/test_reports/environment.py index 98b82b7..c9e6452 100644 --- a/sphinxcontrib/test_reports/environment.py +++ b/sphinxcontrib/test_reports/environment.py @@ -50,10 +50,10 @@ def safe_add_file(filename: str, app: object) -> None: if add_js_file_fn is not None: add_js_file_fn(data_file) elif data_file.split(".")[-1] == "css": - if css_files is not None and static_data_file not in css_files: - add_css_file_fn = cast(Optional[Callable[[str], object]], getattr(cast(object, app), "add_css_file", None)) - if add_css_file_fn is not None: - add_css_file_fn(data_file) + if hasattr(app.builder, "css_files"): + css_files = [css.filename for css in app.builder.css_files] + if static_data_file not in css_files: + app.add_css_file(data_file) else: raise NotImplementedError( "File type {} not support by save_add_file".format(data_file.split(".")[-1]) diff --git a/sphinxcontrib/test_reports/test_reports.py b/sphinxcontrib/test_reports/test_reports.py index 22ccc90..c7dc721 100644 --- a/sphinxcontrib/test_reports/test_reports.py +++ b/sphinxcontrib/test_reports/test_reports.py @@ -2,6 +2,7 @@ import os import sphinx +import sphinx_needs from docutils.parsers.rst import directives from packaging.version import Version from sphinx.application import Sphinx @@ -47,7 +48,7 @@ def error(self, msg: str) -> object: ... # fmt: on -VERSION = "1.1.1" +VERSION = "1.3.1" def setup(app: Sphinx) -> dict[str, object]: @@ -58,6 +59,8 @@ def setup(app: Sphinx) -> dict[str, object]: * test_report """ + app.add_config_value("tr_file_option", "file", "html") + log = logger log.info("Setting up sphinx-test-reports extension") @@ -189,27 +192,45 @@ def sphinx_needs_update(app: Sphinx, config: Config) -> None: """ sphinx-needs configuration """ + + # Check sphinx-needs version to determine if schema is needed + try: + needs_version = Version(sphinx_needs.__version__) + use_schema = needs_version >= Version("6.0.0") + except ImportError: + # If we can't determine version, assume older version + use_schema = False # Extra options # For details read # https://sphinx-needs.readthedocs.io/en/latest/api.html#sphinx_needs.api.configuration.add_extra_option - cast(object, add_extra_option(app, "file")) + cast(object, add_extra_option(app, getattr(config, "tr_file_option", "file"))) cast(object, add_extra_option(app, "suite")) cast(object, add_extra_option(app, "case")) cast(object, add_extra_option(app, "case_name")) cast(object, add_extra_option(app, "case_parameter")) cast(object, add_extra_option(app, "classname")) - cast(object, add_extra_option(app, "time")) - cast(object, add_extra_option(app, "suites")) - cast(object, add_extra_option(app, "cases")) - - cast(object, add_extra_option(app, "passed")) - cast(object, add_extra_option(app, "skipped")) - cast(object, add_extra_option(app, "failed")) - cast(object, add_extra_option(app, "errors")) - cast(object, add_extra_option(app, "result")) # used by test cases only + # Add schema parameter conditionally based on sphinx-needs version + if use_schema: + add_extra_option(app, "time", schema={"type": "string"}) + add_extra_option(app, "suites", schema={"type": "integer"}) + add_extra_option(app, "cases", schema={"type": "integer"}) + add_extra_option(app, "passed", schema={"type": "integer"}) + add_extra_option(app, "skipped", schema={"type": "integer"}) + add_extra_option(app, "failed", schema={"type": "integer"}) + add_extra_option(app, "errors", schema={"type": "integer"}) + add_extra_option(app, "result", schema={"type": "string"}) + else: + add_extra_option(app, "time") + add_extra_option(app, "suites") + add_extra_option(app, "cases") + add_extra_option(app, "passed") + add_extra_option(app, "skipped") + add_extra_option(app, "failed") + add_extra_option(app, "errors") + add_extra_option(app, "result") # Extra dynamic functions # For details about usage read diff --git a/tests/test_custom_template.py b/tests/test_custom_template.py index dd1a273..0e29f18 100644 --- a/tests/test_custom_template.py +++ b/tests/test_custom_template.py @@ -28,7 +28,7 @@ def test_custom_template(test_app): def test_custom_utf8_template(test_app): app = test_app app.build() - html = Path(app.outdir / "index.html").read_text() + html = Path(app.outdir / "index.html").read_text(encoding="utf-8") assert "Änderungen" in html assert "Testfälle" in html From 4405acfd501b6991a376b1994c965924a1555e8a Mon Sep 17 00:00:00 2001 From: korbi-web-215 <149482188+korbi-web-215@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:59:51 +0200 Subject: [PATCH 27/29] pre-commits --- .github/workflows/release.yaml | 2 +- .gitignore | Bin 178 -> 216 bytes docs/configuration.rst | 2 +- .../test_reports/directives/test_case.py | 136 ++++++++++++------ .../test_reports/directives/test_common.py | 32 ++++- .../test_reports/directives/test_env.py | 27 ++-- .../test_reports/directives/test_file.py | 50 +++---- .../test_reports/directives/test_report.py | 4 +- .../test_reports/directives/test_results.py | 15 +- .../test_reports/directives/test_suite.py | 108 +++++++++----- sphinxcontrib/test_reports/environment.py | 19 ++- sphinxcontrib/test_reports/exceptions.py | 2 +- .../test_reports/functions/__init__.py | 16 ++- sphinxcontrib/test_reports/jsonparser.py | 44 ++++-- sphinxcontrib/test_reports/junitparser.py | 33 +++-- sphinxcontrib/test_reports/test_reports.py | 24 ++-- 16 files changed, 341 insertions(+), 173 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d3b9f4b..0ad678b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -47,4 +47,4 @@ jobs: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 8184c92800d0ebe242abf249ceeb4e9affb61b70..80670939842867481f3c2fc2ae480928962ec1fc 100644 GIT binary patch literal 216 zcmX|*(F(#a3`P4K$d43^AF)pmd>ylmwKy8-y5boAyNReTxhMA~A(SIe2%a8WFyWLA zxm-p0hew2%mxgdz^BfVzuTq%8jWzYUYv>WE$y1$wq-Ps1ZT#fsOXoTzR*boFXi_%> vgq=&DC8_tamb9(<4|N5TB}lZT^J!2RWG#)WO|G4033rWLckGH*bl~6(AGtnM literal 178 zcmXwxOAf*?3`F;Hlt?&-=n;_ER>3hLqN-7TqC&kp2Gq^W_}OF1Q34!2H#U`&PItzDt_|0omx%dq#DVYP;jHvc4%GDJad3Oqq_+9DG^#^NNCJwv2sbSo@RoHfnvCoK c*-#iX%uOq6wCl@xGM}`sO$yKlEy@pk08dRj8vp None: super().__init__(*args, **kwargs) - def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) -> List[nodes.Element]: + def run( + self, nested: bool = False, suite_count: int = -1, case_count: int = -1 + ) -> List[nodes.Element]: self.prepare_basic_options() results = self.load_test_file() @@ -53,19 +55,19 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) raise TestReportInvalidOptionError("Case or classname not given!") # Typing aliases - TestsuiteDict = Dict[str, object] - TestcaseDict = Dict[str, object] + testsuite_dict = Dict[str, object] + testcase_dict = Dict[str, object] # Gather candidate suites - candidate_suites: List[TestsuiteDict] = [] + candidate_suites: List[testsuite_dict] = [] if results is not None: - candidate_suites = cast(List[TestsuiteDict], results) + candidate_suites = cast(List[testsuite_dict], results) # Handle nested selection if requested - selected_suite: Optional[TestsuiteDict] = None + selected_suite: Optional[testsuite_dict] = None if nested and suite_count >= 0 and candidate_suites: root_suite = candidate_suites[0] - nested_suites = cast(List[TestsuiteDict], root_suite.get("testsuites", [])) + nested_suites = cast(List[testsuite_dict], root_suite.get("testsuites", [])) if 0 <= suite_count < len(nested_suites): selected_suite = nested_suites[suite_count] @@ -82,8 +84,8 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) ) # Select testcase - testcases = cast(List[TestcaseDict], selected_suite.get("testcases", [])) - selected_case: Optional[TestcaseDict] = None + testcases = cast(List[testcase_dict], selected_suite.get("testcases", [])) + selected_case: Optional[testcase_dict] = None for case_obj in testcases: name = str(case_obj.get("name", "")) classname_val = str(case_obj.get("classname", "")) @@ -96,7 +98,12 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) selected_case = case_obj break - if selected_case is None and nested and case_count >= 0 and 0 <= case_count < len(testcases): + if ( + selected_case is None + and nested + and case_count >= 0 + and 0 <= case_count < len(testcases) + ): selected_case = testcases[case_count] if selected_case is None and nested and testcases: @@ -110,35 +117,70 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) result = str(selected_case.get("result", "")) content = self.test_content or "" - if selected_case.get("text") is not None and isinstance(selected_case.get("text"), str) and len(cast(str, selected_case.get("text"))) > 0: + if ( + selected_case.get("text") is not None + and isinstance(selected_case.get("text"), str) + and len(cast(str, selected_case.get("text"))) > 0 + ): content += """ **Text**:: {} -""".format("\n ".join([x.lstrip() for x in cast(str, selected_case.get("text", "")).split("\n")])) +""".format( + "\n ".join( + [ + x.lstrip() + for x in cast(str, selected_case.get("text", "")).split("\n") + ] + ) + ) - if selected_case.get("message") is not None and isinstance(selected_case.get("message"), str) and len(cast(str, selected_case.get("message"))) > 0: + if ( + selected_case.get("message") is not None + and isinstance(selected_case.get("message"), str) + and len(cast(str, selected_case.get("message"))) > 0 + ): content += """ **Message**:: {} -""".format("\n ".join([x.lstrip() for x in cast(str, selected_case.get("message", "")).split("\n")])) +""".format( + "\n ".join( + [ + x.lstrip() + for x in cast(str, selected_case.get("message", "")).split("\n") + ] + ) + ) - if selected_case.get("system-out") is not None and isinstance(selected_case.get("system-out"), str) and len(cast(str, selected_case.get("system-out"))) > 0: + if ( + selected_case.get("system-out") is not None + and isinstance(selected_case.get("system-out"), str) + and len(cast(str, selected_case.get("system-out"))) > 0 + ): content += """ **System-out**:: {} -""".format("\n ".join([x.lstrip() for x in cast(str, selected_case.get("system-out", "")).split("\n")])) +""".format( + "\n ".join( + [ + x.lstrip() + for x in cast(str, selected_case.get("system-out", "")).split( + "\n" + ) + ] + ) + ) time = float(selected_case.get("time", 0.0)) - + # Ensure time is a string, SN 6.0.0 requires to be in one specific type # and it is set to string for backwards compatibility if isinstance(time, (int, float)): @@ -159,7 +201,10 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) import re - groups: Optional[Match[str]] = re.match(r"^(?P[^\[]+)($|\[(?P.*)?\])", str(selected_case.get("name", ""))) + groups: Optional[Match[str]] = re.match( + r"^(?P[^\[]+)($|\[(?P.*)?\])", + str(selected_case.get("name", "")), + ) if groups is not None: case_name = groups["name"] case_parameter = groups["param"] @@ -187,30 +232,33 @@ def run(self, nested: bool = False, suite_count: int = -1, case_count: int = -1) main_section: List[nodes.Element] = [] # Merge all options including extra ones - main_section += cast(List[nodes.Element], add_need( - self.app, - self.state, - docname, - self.lineno, - need_type=self.need_type, - title=self.test_name, - id=self.test_id, - content=content, - links=self.test_links, - tags=self.test_tags, - status=self.test_status, - collapse=self.collapse, - file=self.test_file_given, - suite=str(selected_suite.get("name", "")), - case=case_full_name, - case_name=case_name, - case_parameter=case_parameter, - classname=class_name, - result=result, - time=time_str, - style=style, - **(self.extra_options or {}), - )) + main_section += cast( + List[nodes.Element], + add_need( + self.app, + self.state, + docname, + self.lineno, + need_type=self.need_type, + title=self.test_name, + id=self.test_id, + content=content, + links=self.test_links, + tags=self.test_tags, + status=self.test_status, + collapse=self.collapse, + file=self.test_file_given, + suite=str(selected_suite.get("name", "")), + case=case_full_name, + case_name=case_name, + case_parameter=case_parameter, + classname=class_name, + result=result, + time=time_str, + style=style, + **(self.extra_options or {}), + ), + ) add_doc(self.env, docname) return main_section diff --git a/sphinxcontrib/test_reports/directives/test_common.py b/sphinxcontrib/test_reports/directives/test_common.py index 025724b..7566983 100644 --- a/sphinxcontrib/test_reports/directives/test_common.py +++ b/sphinxcontrib/test_reports/directives/test_common.py @@ -6,7 +6,17 @@ import os import pathlib from importlib.metadata import version -from typing import Any, Dict, Optional, List, Tuple, Mapping, MutableMapping, Union, Protocol, cast +from typing import ( + Dict, + List, + Mapping, + MutableMapping, + Optional, + Protocol, + Tuple, + Union, + cast, +) from docutils.parsers.rst import Directive from sphinx.util import logging @@ -58,11 +68,13 @@ class TestCommonDirective(Directive): def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - self.env: _SphinxEnvProtocol = cast(_SphinxEnvProtocol, self.state.document.settings.env) + self.env: _SphinxEnvProtocol = cast( + _SphinxEnvProtocol, self.state.document.settings.env + ) self.app: _SphinxAppProtocol = self.env.app if not hasattr(self.app, "testreport_data"): empty_store: Dict[str, List[Dict[str, object]]] = {} - setattr(self.app, "testreport_data", empty_store) + self.app.testreport_data = empty_store self.test_file: Optional[str] = None self.results: Optional[List[Dict[str, object]]] = None @@ -82,7 +94,9 @@ def __init__(self, *args: object, **kwargs: object) -> None: def collect_extra_options(self) -> None: """Collect any extra options and their values that were specified in the directive""" - tr_extra_options = cast(Optional[List[str]], getattr(self.app.config, "tr_extra_options", None)) + tr_extra_options = cast( + Optional[List[str]], getattr(self.app.config, "tr_extra_options", None) + ) extra: Dict[str, object] = {} if tr_extra_options: @@ -120,7 +134,11 @@ def load_test_file(self) -> Optional[List[Dict[str, object]]]: if os.path.splitext(self.test_file)[1] == ".json": json_mapping_all = self.app.config.tr_json_mapping mapping_values = list(json_mapping_all.values()) - mapping: MappingEntryType = mapping_values[0] if mapping_values else {"testcase": {}, "testsuite": {}} + mapping: MappingEntryType = ( + mapping_values[0] + if mapping_values + else {"testcase": {}, "testsuite": {}} + ) parser = JsonParser(self.test_file, json_mapping=mapping) else: parser = JUnitParser(self.test_file) @@ -168,7 +186,9 @@ def prepare_basic_options(self) -> None: raise SphinxError("ID must be set for test-report.") self.test_file = cast(Optional[str], self.options.get("file")) - self.test_file_given = str(self.test_file) if self.test_file is not None else None + self.test_file_given = ( + str(self.test_file) if self.test_file is not None else None + ) self.test_links = cast(str, self.options.get("links", "")) self.test_tags = cast(str, self.options.get("tags", "")) diff --git a/sphinxcontrib/test_reports/directives/test_env.py b/sphinxcontrib/test_reports/directives/test_env.py index 385b183..88866c4 100644 --- a/sphinxcontrib/test_reports/directives/test_env.py +++ b/sphinxcontrib/test_reports/directives/test_env.py @@ -3,7 +3,7 @@ import copy import json import os -from typing import List, Optional, Tuple, Dict, Iterable, Protocol, cast +from typing import Dict, Iterable, List, Optional, Protocol, Tuple, cast import sphinx from docutils import nodes @@ -13,32 +13,39 @@ # ---------- Typing ---------- + class LoggerProtocol(Protocol): def debug(self, msg: str) -> object: ... def info(self, msg: str) -> object: ... def warning(self, msg: str) -> object: ... def error(self, msg: str) -> object: ... + class _AppConfigProtocol(Protocol): tr_rootdir: str + class _AppProtocol(Protocol): config: _AppConfigProtocol + # ---------- Logger ---------- sphinx_version = sphinx.__version__ logger: LoggerProtocol if Version(sphinx_version) >= Version("1.6"): from sphinx.util import logging as sphinx_logging + logger = cast(LoggerProtocol, sphinx_logging.getLogger(__name__)) else: import logging as std_logging + std_logging.basicConfig() logger = cast(LoggerProtocol, std_logging.getLogger(__name__)) # ---------- Nodes & Directive ---------- + class EnvReport(nodes.General, nodes.Element): pass @@ -100,13 +107,13 @@ def run(self) -> List[nodes.Node]: json_path = os.path.join(root_path, json_path) if not os.path.exists(json_path): - raise JsonFileNotFound(f"The given file does not exist: {json_path}") + raise JsonFileNotFoundError(f"The given file does not exist: {json_path}") with open(json_path) as fp_json: try: results: Dict[str, Dict[str, object]] = json.load(fp_json) except ValueError as exc: - raise InvalidJsonFile( + raise InvalidJsonFileError( "The given file {} is not a valid JSON".format( json_path.split("/")[-1] ) @@ -157,7 +164,9 @@ def run(self) -> List[nodes.Node]: code_block = nodes.literal_block(results_string, results_string) code_block["language"] = "json" section += code_block # nodes.literal_block(results, results) - main_section.append(section) # nodes.literal_block(enviro, results[enviro]) + main_section.append( + section + ) # nodes.literal_block(enviro, results[enviro]) del temp_dict2 elif "raw" in self.options and self.req_env_list is not None: @@ -190,7 +199,9 @@ def run(self) -> List[nodes.Node]: return main_section - def _crete_table_b(self, enviro: str, results: Dict[str, Dict[str, object]]) -> List[nodes.Node]: + def _crete_table_b( + self, enviro: str, results: Dict[str, Dict[str, object]] + ) -> List[nodes.Node]: main_section: List[nodes.Node] = [] section = nodes.section() section += nodes.title(text=enviro) @@ -246,13 +257,13 @@ def _create_rows(self, row_cells: Iterable[object]) -> nodes.row: return row -class InvalidJsonFile(Exception): +class InvalidJsonFileError(Exception): pass -class JsonFileNotFound(Exception): +class JsonFileNotFoundError(Exception): pass -class InvalidEnvRequested(Exception): +class InvalidEnvRequestedError(Exception): pass diff --git a/sphinxcontrib/test_reports/directives/test_file.py b/sphinxcontrib/test_reports/directives/test_file.py index ae94f7e..1eef51a 100644 --- a/sphinxcontrib/test_reports/directives/test_file.py +++ b/sphinxcontrib/test_reports/directives/test_file.py @@ -1,4 +1,5 @@ import hashlib +from typing import Dict, List, Optional, cast from docutils import nodes from docutils.parsers.rst import directives @@ -9,8 +10,6 @@ from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import TestReportIncompleteConfigurationError -from typing import Dict, List, Optional, cast - class TestFile(nodes.General, nodes.Element): pass @@ -81,28 +80,31 @@ def as_int(val: object) -> int: main_section = [] # type: List[nodes.Element] docname = cast(str, self.state.document.settings.env.docname) - main_section += cast(List[nodes.Element], add_need( - self.app, - self.state, - docname, - self.lineno, - need_type=self.need_type, - title=self.test_name, - id=self.test_id, - content=self.test_content, - links=self.test_links, - tags=self.test_tags, - status=self.test_status, - collapse=self.collapse, - file=self.test_file_given, - suites=suites, - cases=cases, - passed=passed, - skipped=skipped, - failed=failed, - errors=errors, - **(self.extra_options or {}), - )) + main_section += cast( + List[nodes.Element], + add_need( + self.app, + self.state, + docname, + self.lineno, + need_type=self.need_type, + title=self.test_name, + id=self.test_id, + content=self.test_content, + links=self.test_links, + tags=self.test_tags, + status=self.test_status, + collapse=self.collapse, + file=self.test_file_given, + suites=suites, + cases=cases, + passed=passed, + skipped=skipped, + failed=failed, + errors=errors, + **(self.extra_options or {}), + ), + ) if ( "auto_cases" in self.options.keys() diff --git a/sphinxcontrib/test_reports/directives/test_report.py b/sphinxcontrib/test_reports/directives/test_report.py index 1c3a098..fa4816c 100644 --- a/sphinxcontrib/test_reports/directives/test_report.py +++ b/sphinxcontrib/test_reports/directives/test_report.py @@ -1,9 +1,9 @@ # fmt: off import pathlib +from typing import Dict, List, Protocol, cast from docutils import nodes from docutils.parsers.rst import directives -from typing import List, Dict, Optional, Protocol, cast from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import InvalidConfigurationError @@ -55,7 +55,7 @@ def run(self) -> List[nodes.Element]: if tr_template.is_absolute(): template_path = tr_template else: - app_confdir = cast(str, getattr(self.app, "confdir")) + app_confdir = cast(str, self.app.confdir) template_path = pathlib.Path(app_confdir) / tr_template if not template_path.is_file(): diff --git a/sphinxcontrib/test_reports/directives/test_results.py b/sphinxcontrib/test_reports/directives/test_results.py index 1d1a313..2612f0b 100644 --- a/sphinxcontrib/test_reports/directives/test_results.py +++ b/sphinxcontrib/test_reports/directives/test_results.py @@ -1,19 +1,24 @@ # sphinxcontrib/test_reports/directives/test_results.py import os -from typing import List, Tuple, Dict, Union, cast +from typing import Dict, List, Tuple, Union, cast + from docutils import nodes from docutils.parsers.rst import Directive -from sphinxcontrib.test_reports.junitparser import JUnitParser from sphinx.environment import BuildEnvironment +from sphinxcontrib.test_reports.junitparser import JUnitParser TestcaseDict = Dict[str, Union[str, int, float]] -TestsuiteDict = Dict[str, Union[str, int, float, List[TestcaseDict], List["TestsuiteDict"]]] +TestsuiteDict = Dict[ + str, Union[str, int, float, List[TestcaseDict], List["TestsuiteDict"]] +] + class TestResults(nodes.General, nodes.Element): pass + class TestResultsDirective(Directive): """ Directive for showing test results. @@ -100,7 +105,9 @@ def _create_testcase_row(self, testcase: TestcaseDict) -> nodes.row: row = nodes.row(classes=cast(List[str], [result_class])) for index, cell in enumerate(row_cells): - entry = nodes.entry(classes=cast(List[str], [result_class, self.header[index]])) + entry = nodes.entry( + classes=cast(List[str], [result_class, self.header[index]]) + ) entry += nodes.paragraph(text=cell, classes=cast(List[str], [result_class])) row += entry diff --git a/sphinxcontrib/test_reports/directives/test_suite.py b/sphinxcontrib/test_reports/directives/test_suite.py index d7b1282..cddfad1 100644 --- a/sphinxcontrib/test_reports/directives/test_suite.py +++ b/sphinxcontrib/test_reports/directives/test_suite.py @@ -1,23 +1,37 @@ from __future__ import annotations import hashlib -from typing import Callable, Dict, List, Optional, TypedDict, cast, Protocol, runtime_checkable, ClassVar, Tuple +from typing import ( + Callable, + ClassVar, + Dict, + List, + Optional, + Protocol, + Tuple, + TypedDict, + cast, + runtime_checkable, +) + from docutils import nodes from docutils.nodes import Node from docutils.parsers.rst import directives from sphinx_needs.api import add_need from sphinx_needs.utils import add_doc + import sphinxcontrib.test_reports.directives.test_case as test_case_mod from sphinxcontrib.test_reports.directives.test_common import TestCommonDirective from sphinxcontrib.test_reports.exceptions import TestReportInvalidOptionError - # --------- TypedDicts for parser results ------------ + class TestCaseDict(TypedDict): name: str classname: str + class TestSuiteDict(TypedDict, total=False): name: str tests: int @@ -32,6 +46,7 @@ class TestSuiteDict(TypedDict, total=False): # --------- Protocol for required config fields --------- + @runtime_checkable class _SuiteConfigProtocol(Protocol): tr_suite_id_length: int @@ -42,6 +57,7 @@ class _SuiteConfigProtocol(Protocol): # --------- Node class --------- + class TestSuite(nodes.General, nodes.Element): pass @@ -90,12 +106,16 @@ def run(self, nested: bool = False, count: int = -1) -> List[Node]: # If nested, access the first element's nested suites if nested: if not results: - raise TestReportInvalidOptionError("No suites available for nested access.") + raise TestReportInvalidOptionError( + "No suites available for nested access." + ) results = results[0].get("testsuite_nested", []) # Get target suite by name from options suite_name_obj = self.options.get("suite") - suite_name = str(suite_name_obj) if isinstance(suite_name_obj, (str, bytes)) else None + suite_name = ( + str(suite_name_obj) if isinstance(suite_name_obj, (str, bytes)) else None + ) if not suite_name: raise TestReportInvalidOptionError("Suite not given!") @@ -125,28 +145,31 @@ def run(self, nested: bool = False, count: int = -1) -> List[Node]: docname = cast(str, self.state.document.settings.env.docname) # Create the need node for this suite - need_nodes = cast(List[Node], add_need( - self.app, - self.state, - docname, - self.lineno, - need_type=self.need_type, - title=self.test_name, - id=self.test_id, - content=self.test_content, - links=self.test_links, - tags=self.test_tags, - status=self.test_status, - collapse=self.collapse, - file=self.test_file_given, - suite=suite.get("name", ""), - cases=cases_count, - passed=passed, - skipped=skipped, - failed=failed, - errors=errors, - **(self.extra_options or {}), - )) + need_nodes = cast( + List[Node], + add_need( + self.app, + self.state, + docname, + self.lineno, + need_type=self.need_type, + title=self.test_name, + id=self.test_id, + content=self.test_content, + links=self.test_links, + tags=self.test_tags, + status=self.test_status, + collapse=self.collapse, + file=self.test_file_given, + suite=suite.get("name", ""), + cases=cases_count, + passed=passed, + skipped=skipped, + failed=failed, + errors=errors, + **(self.extra_options or {}), + ), + ) main_section += need_nodes # --- Handle nested suites if current suite has no testcases --- @@ -192,17 +215,31 @@ def run(self, nested: bool = False, count: int = -1) -> List[Node]: ) # Run nested suite directive - main_section += cast(List[Node], suite_directive.run(nested=True, count=access_count)) + main_section += cast( + List[Node], suite_directive.run(nested=True, count=access_count) + ) access_count += 1 # --- Automatically create testcase nodes if configured --- - if "auto_cases" in self.options.keys() and isinstance(testcases_list, list) and testcases_list: + if ( + "auto_cases" in self.options.keys() + and isinstance(testcases_list, list) + and testcases_list + ): case_count = 0 for case in testcases_list: cfg = self._get_cfg_suite() base_id = self.test_id or "" - compound = (case.get("classname", "") + "\x00" + case.get("name", "")).encode("UTF-8") - case_id = base_id + "_" + hashlib.sha1(compound).hexdigest().upper()[: cfg.tr_case_id_length] + compound = ( + case.get("classname", "") + "\x00" + case.get("name", "") + ).encode("UTF-8") + case_id = ( + base_id + + "_" + + hashlib.sha1(compound) + .hexdigest() + .upper()[: cfg.tr_case_id_length] + ) if case_id in self.case_ids: raise Exception(f"Case ID exists: {case_id}") @@ -233,10 +270,15 @@ def run(self, nested: bool = False, count: int = -1) -> List[Node]: self.state_machine, ) - is_nested = (("testsuite_nested" in suite and isinstance(suite.get("testsuite_nested"), list) and len(cast(List[object], suite.get("testsuite_nested"))) > 0) - or nested) + is_nested = ( + "testsuite_nested" in suite + and isinstance(suite.get("testsuite_nested"), list) + and len(cast(List[object], suite.get("testsuite_nested"))) > 0 + ) or nested - main_section += cast(List[Node], case_directive.run(is_nested, count, case_count)) + main_section += cast( + List[Node], case_directive.run(is_nested, count, case_count) + ) if is_nested: case_count += 1 diff --git a/sphinxcontrib/test_reports/environment.py b/sphinxcontrib/test_reports/environment.py index c9e6452..c1995d8 100644 --- a/sphinxcontrib/test_reports/environment.py +++ b/sphinxcontrib/test_reports/environment.py @@ -1,5 +1,5 @@ import os -from typing import TYPE_CHECKING, Optional, List, Callable, Iterable, cast +from typing import Callable, Iterable, List, Optional, cast import sphinx from packaging.version import Version @@ -11,7 +11,7 @@ sphinx_version = sphinx.__version__ if Version(sphinx_version) >= Version("1.6"): try: - from sphinx.util.display import status_iterator as _status_iterator + from sphinx.util.display import status_iterator as _status_iterator except Exception: from sphinx.util import status_iterator as _status_iterator @@ -46,7 +46,10 @@ def safe_add_file(filename: str, app: object) -> None: if data_file.split(".")[-1] == "js": if script_files is not None and static_data_file not in script_files: - add_js_file_fn = cast(Optional[Callable[[str], object]], getattr(cast(object, app), "add_js_file", None)) + add_js_file_fn = cast( + Optional[Callable[[str], object]], + getattr(cast(object, app), "add_js_file", None), + ) if add_js_file_fn is not None: add_js_file_fn(data_file) elif data_file.split(".")[-1] == "css": @@ -89,8 +92,8 @@ def safe_remove_file(filename: str, app: object) -> None: # Base implementation from sphinxcontrib-images # https://github.com/spinus/sphinxcontrib-images/blob/master/sphinxcontrib/images.py#L203 def install_styles_static_files(app: object, env: object) -> None: - builder_obj: object = cast(object, getattr(app, "builder")) - outdir = cast(str, getattr(builder_obj, "outdir")) + builder_obj: object = cast(object, app.builder) + outdir = cast(str, builder_obj.outdir) statics_dir_path: str = os.path.join(outdir, STATICS_DIR_NAME) dest_path: str = os.path.join(statics_dir_path, "sphinx-test-results") @@ -101,7 +104,7 @@ def install_styles_static_files(app: object, env: object) -> None: if Version(sphinx_version) < Version("1.6"): global status_iterator_typed - status_it = getattr(cast(object, app), "status_iterator") + status_it = cast(object, app).status_iterator status_iterator_typed = cast(StatusIteratorType, status_it) iterator = cast(StatusIteratorType, status_iterator_typed) @@ -122,7 +125,9 @@ def install_styles_static_files(app: object, env: object) -> None: ) print(f"{source_file_path} not found. Copying sphinx-internal blank.css") - dest_file_path: str = os.path.join(dest_path, os.path.basename(source_file_path)) + dest_file_path: str = os.path.join( + dest_path, os.path.basename(source_file_path) + ) if not os.path.exists(os.path.dirname(dest_file_path)): ensuredir(os.path.dirname(dest_file_path)) diff --git a/sphinxcontrib/test_reports/exceptions.py b/sphinxcontrib/test_reports/exceptions.py index dfce0ff..25570c5 100644 --- a/sphinxcontrib/test_reports/exceptions.py +++ b/sphinxcontrib/test_reports/exceptions.py @@ -1,4 +1,4 @@ -from sphinx.errors import SphinxError, SphinxWarning +from sphinx.errors import SphinxError, SphinxWarning __all__ = [ "SphinxError", diff --git a/sphinxcontrib/test_reports/functions/__init__.py b/sphinxcontrib/test_reports/functions/__init__.py index 3c243fe..fab28eb 100644 --- a/sphinxcontrib/test_reports/functions/__init__.py +++ b/sphinxcontrib/test_reports/functions/__init__.py @@ -1,4 +1,4 @@ -from typing import List, Dict +from typing import Dict, List def tr_link( @@ -8,7 +8,7 @@ def tr_link( test_option: str, target_option: str, *args: object, - **kwargs: object + **kwargs: object, ) -> List[str]: if test_option not in need: return [] @@ -20,12 +20,16 @@ def tr_link( for need_target in needs.values(): if target_option not in need_target: continue - for test_opt in test_opt_values: + # Directly filter matching test_opt_values + matched_ids = [ + need_target["id"] + for test_opt in test_opt_values if ( test_opt == need_target[target_option] and test_opt is not None - and len(test_opt) > 0 # fmt: skip - ): - links.append(need_target["id"]) + and len(test_opt) > 0 + ) + ] + links.extend(matched_ids) return links diff --git a/sphinxcontrib/test_reports/jsonparser.py b/sphinxcontrib/test_reports/jsonparser.py index cd39d48..800f805 100644 --- a/sphinxcontrib/test_reports/jsonparser.py +++ b/sphinxcontrib/test_reports/jsonparser.py @@ -7,16 +7,15 @@ """ import json -import operator import os from typing import ( Dict, List, - Union, - Sequence, Optional, + Sequence, Tuple, TypedDict, + Union, cast, ) @@ -29,7 +28,7 @@ class MappingEntry(TypedDict): def dict_get( root: Union[Dict[Union[str, int], object], List[object]], items: Sequence[Union[str, int]], - default: Optional[object] = None + default: Optional[object] = None, ) -> object: """ Access a nested object in root by item sequence. @@ -49,20 +48,23 @@ def dict_get( return default - class JsonParser: json_path: str json_data: List[Dict[str, object]] json_mapping: MappingEntry - def __init__(self, json_path: str, *args: object, **kwargs: Dict[str, object]) -> None: + def __init__( + self, json_path: str, *args: object, **kwargs: Dict[str, object] + ) -> None: self.json_path = json_path if not os.path.exists(self.json_path): - raise JsonFileMissing(f"The given file does not exist: {self.json_path}") + raise JsonFileMissingError( + f"The given file does not exist: {self.json_path}" + ) with open(self.json_path, encoding="utf-8") as jfile: - data_raw = json.load(jfile) # type: ignore + data_raw = json.load(jfile) # type: ignore[assignment] data: List[Dict[str, object]] = cast(List[Dict[str, object]], data_raw) if not isinstance(data, list): @@ -72,8 +74,7 @@ def __init__(self, json_path: str, *args: object, **kwargs: Dict[str, object]) - mapping_fallback: MappingEntry = {"testcase": {}, "testsuite": {}} self.json_mapping = cast( - MappingEntry, - kwargs.get("json_mapping", mapping_fallback) + MappingEntry, kwargs.get("json_mapping", mapping_fallback) ) def validate(self) -> bool: @@ -89,14 +90,18 @@ def parse(self) -> List[Dict[str, object]]: :return: list of test suites as dictionaries """ - def parse_testcase(json_dict: Dict[Union[str, int], object]) -> Dict[str, object]: + def parse_testcase( + json_dict: Dict[Union[str, int], object], + ) -> Dict[str, object]: tc_mapping = self.json_mapping.get("testcase", {}) return { k: dict_get(json_dict, path, fallback) for k, (path, fallback) in tc_mapping.items() } - def parse_testsuite(json_dict: Dict[Union[str, int], object]) -> Dict[str, object]: + def parse_testsuite( + json_dict: Dict[Union[str, int], object], + ) -> Dict[str, object]: ts_mapping = self.json_mapping.get("testsuite", {}) ts_dict: Dict[str, object] = { k: dict_get(json_dict, path, fallback) @@ -110,7 +115,9 @@ def parse_testsuite(json_dict: Dict[Union[str, int], object]) -> Dict[str, objec testcase_entry = ts_mapping.get("testcases") if testcase_entry: - testcases_raw = dict_get(json_dict, testcase_entry[0], testcase_entry[1]) + testcases_raw = dict_get( + json_dict, testcase_entry[0], testcase_entry[1] + ) if isinstance(testcases_raw, list): for item in testcases_raw: if isinstance(item, dict): @@ -121,11 +128,18 @@ def parse_testsuite(json_dict: Dict[Union[str, int], object]) -> Dict[str, objec # main flow starts here - return [parse_testsuite(cast(Dict[Union[str, int], object], ts)) for ts in self.json_data] + suites = [ts for ts in self.json_data if isinstance(ts, dict)] + junit_dict = [parse_testsuite(ts) for ts in suites] + + return junit_dict def docutils_table(self) -> None: pass -class JsonFileMissing(Exception): +class JsonFileMissingError(Exception): + pass + + +class JUnitFileMissingError(Exception): pass diff --git a/sphinxcontrib/test_reports/junitparser.py b/sphinxcontrib/test_reports/junitparser.py index f202053..5b69820 100644 --- a/sphinxcontrib/test_reports/junitparser.py +++ b/sphinxcontrib/test_reports/junitparser.py @@ -1,10 +1,12 @@ """ JUnit XML parser """ + import os -from typing import Optional, Dict, List, cast +from typing import Dict, List, Optional, cast + from lxml import etree, objectify -from lxml.etree import _ElementTree, _Element +from lxml.etree import _Element, _ElementTree class JUnitParser: @@ -24,7 +26,9 @@ def __init__(self, junit_xml: str, junit_xsd: Optional[str] = None) -> None: self.junit_xsd_path = junit_xsd if not os.path.exists(self.junit_xml_path): - raise JUnitFileMissing(f"The given file does not exist: {self.junit_xml_path}") + raise JUnitFileMissingError( + f"The given file does not exist: {self.junit_xml_path}" + ) self.junit_schema_doc = None self.xmlschema = None @@ -62,7 +66,7 @@ def parse_testcase(testcase: _Element) -> Dict[str, object]: # The following data is normally a subnode (e.g. skipped/failure). # We integrate it right into the testcase for better handling if hasattr(testcase, "skipped"): - skipped = cast(_Element, getattr(testcase, "skipped")) + skipped = cast(_Element, testcase.skipped) tc_dict["result"] = "skipped" tc_dict["type"] = str(skipped.attrib.get("type", "unknown")) tc_dict["text"] = str(skipped.text or "") @@ -70,7 +74,7 @@ def parse_testcase(testcase: _Element) -> Dict[str, object]: # result.text can be None for pytest xfail test cases tc_dict["message"] = str(skipped.attrib.get("message", "unknown")) elif hasattr(testcase, "failure"): - failure = cast(_Element, getattr(testcase, "failure")) + failure = cast(_Element, testcase.failure) tc_dict["result"] = "failure" tc_dict["type"] = str(failure.attrib.get("type", "unknown")) # tc_dict["text"] = re.sub(r"[\n\t]*", "", result.text) # Removes newlines and tabs @@ -121,15 +125,19 @@ def parse_testsuite(testsuite: _Element) -> Dict[str, object]: # add nested testsuite objects to if hasattr(testsuite, "testsuite"): - nested_suites = cast(List[_Element], getattr(testsuite, "testsuite")) + nested_suites = cast(List[_Element], testsuite.testsuite) for ts in nested_suites: # dict from inner parse - cast(List[Dict[str, object]], ts_dict["testsuite_nested"]).append(parse_testsuite(ts)) + cast(List[Dict[str, object]], ts_dict["testsuite_nested"]).append( + parse_testsuite(ts) + ) if hasattr(testsuite, "testcase"): - testcases = cast(List[_Element], getattr(testsuite, "testcase")) + testcases = cast(List[_Element], testsuite.testcase) for tc in testcases: - cast(List[Dict[str, object]], ts_dict["testcases"]).append(parse_testcase(tc)) + cast(List[Dict[str, object]], ts_dict["testcases"]).append( + parse_testcase(tc) + ) return ts_dict @@ -139,9 +147,8 @@ def parse_testsuite(testsuite: _Element) -> Dict[str, object]: if self.junit_xml_object.tag == "testsuites": if hasattr(self.junit_xml_object, "testsuite"): - suites = cast(List[_Element], getattr(self.junit_xml_object, "testsuite")) - for ts in suites: - junit_dict.append(parse_testsuite(ts)) + suites = cast(List[_Element], self.junit_xml_object.testsuite) + junit_dict.extend([parse_testsuite(ts) for ts in suites]) else: junit_dict.append(parse_testsuite(self.junit_xml_object)) @@ -151,5 +158,5 @@ def docutils_table(self) -> None: pass -class JUnitFileMissing(Exception): +class JUnitFileMissingError(Exception): pass diff --git a/sphinxcontrib/test_reports/test_reports.py b/sphinxcontrib/test_reports/test_reports.py index c7dc721..b2d61d2 100644 --- a/sphinxcontrib/test_reports/test_reports.py +++ b/sphinxcontrib/test_reports/test_reports.py @@ -1,5 +1,6 @@ # fmt: off import os +from typing import Dict, List, Protocol, cast import sphinx import sphinx_needs @@ -29,7 +30,6 @@ from sphinxcontrib.test_reports.environment import install_styles_static_files from sphinxcontrib.test_reports.functions import tr_link -from typing import Any, Dict, List, Optional, Protocol, cast class LoggerProtocol(Protocol): def debug(self, msg: str) -> object: ... @@ -60,7 +60,7 @@ def setup(app: Sphinx) -> dict[str, object]: """ app.add_config_value("tr_file_option", "file", "html") - + log = logger log.info("Setting up sphinx-test-reports extension") @@ -68,17 +68,25 @@ def setup(app: Sphinx) -> dict[str, object]: app.add_config_value("tr_rootdir", cast(str, app.confdir), "html") app.add_config_value( "tr_file", - cast(List[str], ["test-file", "testfile", "Test-File", "TF_", "#ffffff", "node"]), + cast( + List[str], ["test-file", "testfile", "Test-File", "TF_", "#ffffff", "node"] + ), "html", ) app.add_config_value( "tr_suite", - cast(List[str], ["test-suite", "testsuite", "Test-Suite", "TS_", "#cccccc", "folder"]), + cast( + List[str], + ["test-suite", "testsuite", "Test-Suite", "TS_", "#cccccc", "folder"], + ), "html", ) app.add_config_value( "tr_case", - cast(List[str], ["test-case", "testcase", "Test-Case", "TC_", "#999999", "rectangle"]), + cast( + List[str], + ["test-case", "testcase", "Test-Case", "TC_", "#999999", "rectangle"], + ), "html", ) @@ -172,10 +180,10 @@ def tr_preparation(app: Sphinx, *args: object) -> None: Prepares needed vars in the app context. """ if not hasattr(app, "tr_types"): - setattr(app, "tr_types", {}) + app.tr_types = {} # Collects the configured test-report node types - tr_types = cast(Dict[str, List[str]], getattr(app, "tr_types")) + tr_types = cast(Dict[str, List[str]], app.tr_types) tr_file = cast(List[str], app.config.tr_file) tr_suite = cast(List[str], app.config.tr_suite) tr_case = cast(List[str], app.config.tr_case) @@ -192,7 +200,7 @@ def sphinx_needs_update(app: Sphinx, config: Config) -> None: """ sphinx-needs configuration """ - + # Check sphinx-needs version to determine if schema is needed try: needs_version = Version(sphinx_needs.__version__) From 128702d4756e158921ddc80d28a74987029c13b2 Mon Sep 17 00:00:00 2001 From: korbi-web-215 <149482188+korbi-web-215@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:29:22 +0200 Subject: [PATCH 28/29] mypy fixes --- .../test_reports/directives/test_case.py | 18 ++++++++---------- .../test_reports/directives/test_common.py | 16 ++++++++-------- sphinxcontrib/test_reports/jsonparser.py | 8 +++++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/sphinxcontrib/test_reports/directives/test_case.py b/sphinxcontrib/test_reports/directives/test_case.py index b7b88ae..1129421 100644 --- a/sphinxcontrib/test_reports/directives/test_case.py +++ b/sphinxcontrib/test_reports/directives/test_case.py @@ -54,20 +54,18 @@ def run( if case_full_name is None and class_name is None: raise TestReportInvalidOptionError("Case or classname not given!") - # Typing aliases - testsuite_dict = Dict[str, object] - testcase_dict = Dict[str, object] - # Gather candidate suites - candidate_suites: List[testsuite_dict] = [] + candidate_suites: List[Dict[str, object]] = [] if results is not None: - candidate_suites = cast(List[testsuite_dict], results) + candidate_suites = cast(List[Dict[str, object]], results) # Handle nested selection if requested - selected_suite: Optional[testsuite_dict] = None + selected_suite: Optional[Dict[str, object]] = None if nested and suite_count >= 0 and candidate_suites: root_suite = candidate_suites[0] - nested_suites = cast(List[testsuite_dict], root_suite.get("testsuites", [])) + nested_suites = cast( + List[Dict[str, object]], root_suite.get("testsuites", []) + ) if 0 <= suite_count < len(nested_suites): selected_suite = nested_suites[suite_count] @@ -84,8 +82,8 @@ def run( ) # Select testcase - testcases = cast(List[testcase_dict], selected_suite.get("testcases", [])) - selected_case: Optional[testcase_dict] = None + testcases = cast(List[Dict[str, object]], selected_suite.get("testcases", [])) + selected_case: Optional[Dict[str, object]] = None for case_obj in testcases: name = str(case_obj.get("name", "")) classname_val = str(case_obj.get("classname", "")) diff --git a/sphinxcontrib/test_reports/directives/test_common.py b/sphinxcontrib/test_reports/directives/test_common.py index 7566983..43178f4 100644 --- a/sphinxcontrib/test_reports/directives/test_common.py +++ b/sphinxcontrib/test_reports/directives/test_common.py @@ -20,7 +20,7 @@ from docutils.parsers.rst import Directive from sphinx.util import logging -from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.config import NeedsSphinxConfig # type: ignore[import-untyped] from sphinxcontrib.test_reports.exceptions import ( SphinxError, @@ -32,9 +32,9 @@ sn_major_version = int(version("sphinx-needs").split('.')[0]) if sn_major_version >= 4: - from sphinx_needs.api.need import _make_hashed_id + from sphinx_needs.api.need import _make_hashed_id # type: ignore[import-untyped] else: - from sphinx_needs.api import make_hashed_id + from sphinx_needs.api import make_hashed_id # type: ignore[import-untyped] # fmt: on @@ -67,7 +67,7 @@ class TestCommonDirective(Directive): """ def __init__(self, *args: object, **kwargs: object) -> None: - super().__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # type: ignore[arg-type] self.env: _SphinxEnvProtocol = cast( _SphinxEnvProtocol, self.state.document.settings.env ) @@ -139,7 +139,7 @@ def load_test_file(self) -> Optional[List[Dict[str, object]]]: if mapping_values else {"testcase": {}, "testsuite": {}} ) - parser = JsonParser(self.test_file, json_mapping=mapping) + parser = JsonParser(self.test_file, json_mapping=mapping) # type: ignore[arg-type] else: parser = JUnitParser(self.test_file) testreport_data[self.test_file] = parser.parse() @@ -152,9 +152,9 @@ def prepare_basic_options(self) -> None: Reads and checks the needed basic data like name, id, links, status, ... :return: None """ - self.docname = cast(str, self.state.document.settings.env.docname) - - self.test_name = self.arguments[0] + # mypy: explicit type for Any + self.docname = self.state.document.settings.env.docname # type: ignore[misc] + self.test_name = cast(str, self.arguments[0]) self.test_content = "\n".join(self.content) if self.name != "test-report": self.need_type = self.app.tr_types[self.name][0] diff --git a/sphinxcontrib/test_reports/jsonparser.py b/sphinxcontrib/test_reports/jsonparser.py index 800f805..957d1f1 100644 --- a/sphinxcontrib/test_reports/jsonparser.py +++ b/sphinxcontrib/test_reports/jsonparser.py @@ -50,7 +50,7 @@ def dict_get( class JsonParser: json_path: str - json_data: List[Dict[str, object]] + json_data: List[Dict[str | int, object]] json_mapping: MappingEntry def __init__( @@ -64,8 +64,10 @@ def __init__( ) with open(self.json_path, encoding="utf-8") as jfile: - data_raw = json.load(jfile) # type: ignore[assignment] - data: List[Dict[str, object]] = cast(List[Dict[str, object]], data_raw) + data_raw: object = json.load(jfile) + data: List[Dict[str | int, object]] = cast( + List[Dict[str | int, object]], data_raw + ) if not isinstance(data, list): raise TypeError("Expected top-level JSON to be a list of dicts") From 16ac6f5a97da7829c9258ce8ce9573b9b0085072 Mon Sep 17 00:00:00 2001 From: korbi-web-215 <149482188+korbi-web-215@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:38:02 +0200 Subject: [PATCH 29/29] fix: use getattr for needs_collapse_details in TestCommonDirective --- sphinxcontrib/test_reports/directives/test_common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sphinxcontrib/test_reports/directives/test_common.py b/sphinxcontrib/test_reports/directives/test_common.py index 43178f4..4639711 100644 --- a/sphinxcontrib/test_reports/directives/test_common.py +++ b/sphinxcontrib/test_reports/directives/test_common.py @@ -207,7 +207,9 @@ def prepare_basic_options(self) -> None: elif isinstance(collapse_raw, bool): self.collapse = collapse_raw else: - self.collapse = bool(self.app.config.needs_collapse_details) + self.collapse = bool( + getattr(self.app.config, "needs_collapse_details", True) + ) # Also collect any extra options while we're at it self.collect_extra_options()