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..0ad678b --- /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 diff --git a/.gitignore b/.gitignore index 8184c92..8067093 100644 Binary files a/.gitignore and b/.gitignore differ 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/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 9dbb431..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", ] @@ -99,7 +98,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 diff --git a/sphinxcontrib/test_reports/directives/test_case.py b/sphinxcontrib/test_reports/directives/test_case.py index 39ef4cd..1129421 100644 --- a/sphinxcontrib/test_reports/directives/test_case.py +++ b/sphinxcontrib/test_reports/directives/test_case.py @@ -1,3 +1,5 @@ +from typing import Dict, List, Match, Optional, cast + from docutils import nodes from docutils.parsers.rst import directives from sphinx_needs.api import add_need @@ -34,110 +36,177 @@ 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[nodes.Element]: 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] - - 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 + # Gather candidate suites + candidate_suites: List[Dict[str, object]] = [] + if results is not None: + candidate_suites = cast(List[Dict[str, object]], results) + + # Handle nested selection if requested + 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[Dict[str, object]], root_suite.get("testsuites", []) + ) + if 0 <= suite_count < len(nested_suites): + selected_suite = nested_suites[suite_count] - elif suite_obj["name"] == suite_name: - suite = suite_obj - break + # 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 suite is None: + 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: # noqa: SIM114 # noqa: W503 - case = case_obj + # Select testcase + 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", "")) + 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"] - content = self.test_content - if case["text"] is not None and len(case["text"]) > 0: + 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 + ): 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["message"] is not None 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["system-out"] is not None 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_" + case["result"] + 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 - groups = re.match(r"^(?P[^\[]+)($|\[(?P.*)?\])", case["name"]) - try: + 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"] - except TypeError: + else: case_name = case_full_name case_parameter = "" @@ -145,44 +214,48 @@ def run(self, nested=False, suite_count=-1, case_count=-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]: 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 + docname = cast(str, self.state.document.settings.env.docname) - main_section = [] + main_section: List[nodes.Element] = [] # Merge all options including extra ones - main_section += 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=suite["name"], - case=case_full_name, - case_name=case_name, - case_parameter=case_parameter, - classname=class_name, - result=result, - time=time, - style=style, - **self.extra_options, + 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) diff --git a/sphinxcontrib/test_reports/directives/test_common.py b/sphinxcontrib/test_reports/directives/test_common.py index a0300ed..4639711 100644 --- a/sphinxcontrib/test_reports/directives/test_common.py +++ b/sphinxcontrib/test_reports/directives/test_common.py @@ -6,68 +6,106 @@ import os import pathlib from importlib.metadata import version +from typing import ( + Dict, + List, + Mapping, + MutableMapping, + Optional, + Protocol, + Tuple, + Union, + cast, +) 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, 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]) 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 +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, **kwargs): - super().__init__(*args, **kwargs) - self.env = self.state.document.settings.env - self.app = self.env.app + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) # type: ignore[arg-type] + 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 = {} - - 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 + empty_store: Dict[str, List[Dict[str, object]]] = {} + self.app.testreport_data = empty_store + + self.test_file: Optional[str] = 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 + 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: bool = True + self.need_type: Optional[str] = None + self.extra_options: Optional[Dict[str, object]] = 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 = {} + 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): + def load_test_file(self) -> Optional[List[Dict[str, object]]]: """ Loads the defined test_file under self.test_file. @@ -90,68 +128,88 @@ def load_test_file(self): ) 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] - parser = JsonParser(self.test_file, json_mapping=mapping) + 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) # type: ignore[arg-type] 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): + 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.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] 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( + getattr(self.app.config, "needs_collapse_details", True) + ) # Also collect any extra options while we're at it self.collect_extra_options() diff --git a/sphinxcontrib/test_reports/directives/test_env.py b/sphinxcontrib/test_reports/directives/test_env.py index a3901de..88866c4 100644 --- a/sphinxcontrib/test_reports/directives/test_env.py +++ b/sphinxcontrib/test_reports/directives/test_env.py @@ -1,20 +1,49 @@ +from __future__ import annotations + import copy import json import os +from typing import Dict, Iterable, List, Optional, Protocol, Tuple, cast import sphinx from docutils import nodes from docutils.parsers.rst import Directive, directives from packaging.version import Version +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 + import logging as std_logging - logging.basicConfig() -logger = logging.getLogger(__name__) + std_logging.basicConfig() + logger = cast(LoggerProtocol, std_logging.getLogger(__name__)) + +# ---------- Nodes & Directive ---------- class EnvReport(nodes.General, nodes.Element): @@ -37,48 +66,54 @@ class EnvReportDirective(Directive): final_argument_whitespace = True - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.data_option = self.options.get("data") - self.environments = 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]] - if self.environments is not None: - self.req_env_list_cpy = self.environments.split(",") + 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 environments is not None: + req_env_list_cpy: List[str] = environments.split(",") self.req_env_list = [] - for element in self.req_env_list_cpy: + 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 = self.data_option.split(",") + if data_option is not None: + data_option_list_cpy: List[str] = data_option.split(",") self.data_option_list = [] - for element in self.data_option_list_cpy: + 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 = ("Variable", "Data") - self.colwidths = (1, 1) - - def run(self): - 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) 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 = json.load(fp_json) + 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] ) @@ -95,7 +130,7 @@ def run(self): del not_present_env # Construction idea taken from http://agateau.com/2015/docutils-snippets/ - main_section = [] + main_section: List[nodes.Node] = [] if self.req_env_list is None and "raw" not in self.options: for enviro in results: @@ -129,7 +164,9 @@ def run(self): 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: @@ -143,7 +180,7 @@ def run(self): 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" @@ -157,13 +194,15 @@ def run(self): 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, results): - main_section = [] + 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) @@ -203,7 +242,7 @@ def _crete_table_b(self, enviro, results): return main_section - def _create_rows(self, row_cells): + def _create_rows(self, row_cells: Iterable[object]) -> nodes.row: row = nodes.row() for cell in row_cells: entry = nodes.entry() @@ -214,17 +253,17 @@ def _create_rows(self, row_cells): code_block["language"] = "json" entry += code_block else: - entry += nodes.paragraph(text=cell) + entry += nodes.paragraph(text=cast(str, cell)) return row -class InvalidJsonFile(BaseException): +class InvalidJsonFileError(Exception): pass -class JsonFileNotFound(BaseException): +class JsonFileNotFoundError(Exception): pass -class InvalidEnvRequested(BaseException): +class InvalidEnvRequestedError(Exception): pass diff --git a/sphinxcontrib/test_reports/directives/test_file.py b/sphinxcontrib/test_reports/directives/test_file.py index 38741ac..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 @@ -35,57 +36,74 @@ 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[nodes.Element]: 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[nodes.Element] = [] 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) - - main_section = [] - docname = self.state.document.settings.env.docname - main_section += 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, + 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, + 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 ( @@ -97,36 +115,37 @@ def run(self): "auto_suites for test-file directives." ) - if "auto_suites" in self.options.keys(): - for suite in self.results: - suite_id = self.test_id + 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 - options["suite"] = suite["name"] + 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"]] + 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, "", @@ -138,7 +157,7 @@ def run(self): ) ) - main_section += suite_directive.run() + main_section += cast(List[nodes.Element], suite_directive.run()) add_doc(self.env, docname) diff --git a/sphinxcontrib/test_reports/directives/test_report.py b/sphinxcontrib/test_reports/directives/test_report.py index 57da891..fa4816c 100644 --- a/sphinxcontrib/test_reports/directives/test_report.py +++ b/sphinxcontrib/test_reports/directives/test_report.py @@ -1,5 +1,6 @@ # fmt: off import pathlib +from typing import Dict, List, Protocol, cast from docutils import nodes from docutils.parsers.rst import directives @@ -33,29 +34,36 @@ class TestReportDirective(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): + 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, 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: @@ -63,26 +71,25 @@ def run(self): 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], []) diff --git a/sphinxcontrib/test_reports/directives/test_results.py b/sphinxcontrib/test_reports/directives/test_results.py index 46fc8fe..2612f0b 100644 --- a/sphinxcontrib/test_reports/directives/test_results.py +++ b/sphinxcontrib/test_reports/directives/test_results.py @@ -1,10 +1,19 @@ +# sphinxcontrib/test_reports/directives/test_results.py + import os +from typing import Dict, List, Tuple, Union, cast from docutils import nodes from docutils.parsers.rst import Directive +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"]] +] + class TestResults(nodes.General, nodes.Element): pass @@ -18,43 +27,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 +71,52 @@ 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]] + 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 diff --git a/sphinxcontrib/test_reports/directives/test_suite.py b/sphinxcontrib/test_reports/directives/test_suite.py index 13b0411..cddfad1 100644 --- a/sphinxcontrib/test_reports/directives/test_suite.py +++ b/sphinxcontrib/test_reports/directives/test_suite.py @@ -1,14 +1,62 @@ +from __future__ import annotations + import hashlib +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 +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 + 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 @@ -16,13 +64,13 @@ class TestSuite(nodes.General, nodes.Element): 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, @@ -31,174 +79,211 @@ 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, **kwargs): + def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) - self.case_ids = [] + self.case_ids: List[str] = [] + + 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) + + def _get_cfg_suite(self) -> _SuiteConfigProtocol: + """Cast app config to the extended Suite Protocol.""" + return cast(_SuiteConfigProtocol, self.app.config) - def run(self, nested=False, count=-1): + def run(self, nested: bool = False, count: int = -1) -> List[Node]: self.prepare_basic_options() - self.load_test_file() + results: List[TestSuiteDict] = self._ensure_results_list() + # If nested, access the first element's nested suites if nested: - # access n-th nested suite here - self.results = self.results[0]["testsuite_nested"] - - suite_name = self.options.get("suite") + if not results: + raise TestReportInvalidOptionError( + "No suites available for nested access." + ) + results = results[0].get("testsuite_nested", []) - if suite_name is None: + # 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"] - - passed = suite["passed"] - skipped = suite["skips"] - errors = suite["errors"] - failed = suite["failures"] - - main_section = [] - docname = self.state.document.settings.env.docname - main_section += 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["name"], - cases=cases, - passed=passed, - skipped=skipped, - failed=failed, - errors=errors, - **self.extra_options, + # 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) + + # 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 {}), + ), ) + 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] + ) - # 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 - suite_id += ( - "_" - + hashlib.sha1(suite["name"].encode("UTF-8")) - .hexdigest() - .upper()[: self.app.config.tr_suite_id_length] - ) - - options = self.options - 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"]: - options["links"] = options["links"] + ";" + self.test_id - - arguments = [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["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 - main_section += suite_directive.run(nested=True, count=access_count) - access_count += 1 - # suite has testcases - if "auto_cases" in self.options.keys() and len(suite_obj["testcases"]) > 0: - case_count = 0 - - for case in suite["testcases"]: - case_id = self.test_id - case_id += ( - "_" - + hashlib.sha1( - case["classname"].encode("UTF-8") + case["name"].encode("UTF-8") + # Run nested suite directive + 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 + ): + 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()[: self.app.config.tr_case_id_length] + .upper()[: cfg.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 self.options: - options["links"] = self.test_id - elif 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 = 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 diff --git a/sphinxcontrib/test_reports/environment.py b/sphinxcontrib/test_reports/environment.py index 335b18b..c1995d8 100644 --- a/sphinxcontrib/test_reports/environment.py +++ b/sphinxcontrib/test_reports/environment.py @@ -1,21 +1,32 @@ import os +from typing import Callable, Iterable, List, Optional, 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, app): +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 @@ -25,28 +36,34 @@ 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) + + 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 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]) ) -def safe_remove_file(filename, app): +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. @@ -56,37 +73,42 @@ 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) + + 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, 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: object, env: object) -> None: + 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") - 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) if Version(sphinx_version) < Version("1.6"): - global status_iterator - status_iterator = app.status_iterator + global status_iterator_typed + status_it = 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, @@ -103,7 +125,9 @@ 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)) diff --git a/sphinxcontrib/test_reports/exceptions.py b/sphinxcontrib/test_reports/exceptions.py index 171f3ba..25570c5 100644 --- a/sphinxcontrib/test_reports/exceptions.py +++ b/sphinxcontrib/test_reports/exceptions.py @@ -1,5 +1,15 @@ from sphinx.errors import SphinxError, SphinxWarning +__all__ = [ + "SphinxError", + "SphinxWarning", + "TestReportFileNotSetError", + "TestReportFileInvalidError", + "TestReportInvalidOptionError", + "TestReportIncompleteConfigurationError", + "InvalidConfigurationError", +] + class TestReportFileNotSetError(SphinxError): """ diff --git a/sphinxcontrib/test_reports/functions/__init__.py b/sphinxcontrib/test_reports/functions/__init__.py index 79d4896..fab28eb 100644 --- a/sphinxcontrib/test_reports/functions/__init__.py +++ b/sphinxcontrib/test_reports/functions/__init__.py @@ -1,21 +1,35 @@ -def tr_link(app, need, needs, test_option, target_option, *args, **kwargs): +from typing import Dict, List + + +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() + # 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 19a5e55..957d1f1 100644 --- a/sphinxcontrib/test_reports/jsonparser.py +++ b/sphinxcontrib/test_reports/jsonparser.py @@ -7,13 +7,29 @@ """ 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, + Optional, + Sequence, + Tuple, + TypedDict, + Union, + 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 +39,52 @@ 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 | int, 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}") + raise JsonFileMissingError( + f"The given file does not exist: {self.json_path}" + ) + + with open(self.json_path, encoding="utf-8") as jfile: + data_raw: object = json.load(jfile) + data: List[Dict[str | int, object]] = cast( + List[Dict[str | int, object]], data_raw + ) - self.json_data = [] - with open(self.json_path) as jfile: - self.json_data = json.load(jfile) + 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 +92,56 @@ 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) + suites = [ts for ts in self.json_data if isinstance(ts, dict)] + junit_dict = [parse_testsuite(ts) for ts in suites] - return result_data + return junit_dict - def docutils_table(self): + def docutils_table(self) -> None: pass -class JsonFileMissing(BaseException): +class JsonFileMissingError(Exception): + pass + + +class JUnitFileMissingError(Exception): pass diff --git a/sphinxcontrib/test_reports/junitparser.py b/sphinxcontrib/test_reports/junitparser.py index 7430f6a..5b69820 100644 --- a/sphinxcontrib/test_reports/junitparser.py +++ b/sphinxcontrib/test_reports/junitparser.py @@ -3,40 +3,50 @@ """ import os +from typing import Dict, List, Optional, cast from lxml import etree, objectify +from lxml.etree import _Element, _ElementTree class JUnitParser: - def __init__(self, junit_xml, junit_xsd=None): + 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 = None - self.xmlschema = None - self.valid_xml = None - if not os.path.exists(self.junit_xml_path): - raise JUnitFileMissing( + raise JUnitFileMissingError( f"The given file does not exist: {self.junit_xml_path}" ) - self.junit_xml_doc = etree.parse(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): - self.junit_schema_doc = etree.parse(self.junit_xsd_path) + def validate(self) -> bool: + self.junit_schema_doc = cast(_ElementTree, etree.parse(self.junit_xsd_path)) self.xmlschema = etree.XMLSchema(self.junit_schema_doc) self.valid_xml = self.xmlschema.validate(self.junit_xml_doc) + return bool(self.valid_xml) - return self.valid_xml - - def parse(self): + 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(). @@ -44,33 +54,31 @@ def parse(self): :return: list of test suites as dictionaries """ - def parse_testcase(xml_object): - 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, 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, 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" @@ -79,70 +87,76 @@ def parse_testcase(xml_object): tc_dict["message"] = "" if hasattr(testcase, "system-out"): - tc_dict["system-out"] = 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): - 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], testsuite.testsuite) + for ts in nested_suites: # dict from inner parse - inner_testsuite = parse_testsuite(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(tc) - ts_dict["testcases"].append(new_testcase) + if hasattr(testsuite, "testcase"): + testcases = cast(List[_Element], 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(testsuite_xml_object) - junit_dict.append(complete_testsuite) + if hasattr(self.junit_xml_object, "testsuite"): + suites = cast(List[_Element], self.junit_xml_object.testsuite) + junit_dict.extend([parse_testsuite(ts) for ts in suites]) else: - complete_testsuite = parse_testsuite(self.junit_xml_object) - junit_dict.append(complete_testsuite) + junit_dict.append(parse_testsuite(self.junit_xml_object)) return junit_dict - def docutils_table(self): + def docutils_table(self) -> None: pass -class JUnitFileMissing(BaseException): +class JUnitFileMissingError(Exception): pass diff --git a/sphinxcontrib/test_reports/test_reports.py b/sphinxcontrib/test_reports/test_reports.py index dd505fb..b2d61d2 100644 --- a/sphinxcontrib/test_reports/test_reports.py +++ b/sphinxcontrib/test_reports/test_reports.py @@ -1,7 +1,9 @@ # fmt: off import os +from typing import Dict, List, Protocol, cast import sphinx +import sphinx_needs from docutils.parsers.rst import directives from packaging.version import Version from sphinx.application import Sphinx @@ -28,18 +30,28 @@ from sphinxcontrib.test_reports.environment import install_styles_static_files from sphinxcontrib.test_reports.functions import tr_link + +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: ... + sphinx_version = sphinx.__version__ if Version(sphinx_version) >= Version("1.6"): - from sphinx.util import logging + from sphinx.util import logging as sphinx_logging + logger: LoggerProtocol = cast(LoggerProtocol, sphinx_logging.getLogger(__name__)) else: - import logging + import logging as std_logging + std_logging.basicConfig() + logger: LoggerProtocol = cast(LoggerProtocol, std_logging.getLogger(__name__)) # fmt: on -VERSION = "1.1.1" +VERSION = "1.3.1" -def setup(app: Sphinx): +def setup(app: Sphinx) -> dict[str, object]: """ Setup following directives: * test_results @@ -47,24 +59,34 @@ def setup(app: Sphinx): * test_report """ - log = logging.getLogger(__name__) + app.add_config_value("tr_file_option", "file", "html") + + 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", ) @@ -77,7 +99,7 @@ def setup(app: Sphinx): 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": { @@ -109,24 +131,24 @@ def setup(app: Sphinx): 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 @@ -135,24 +157,25 @@ 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", []) + 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())}" ) -def tr_preparation(app, *args): +def tr_preparation(app: Sphinx, *args: object) -> None: """ Prepares needed vars in the app context. """ @@ -160,13 +183,17 @@ def tr_preparation(app, *args): 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]], 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: @@ -174,35 +201,56 @@ 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 - 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") - - 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") # used by test cases only + 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")) + + # 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 # 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:])) 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