Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,65 @@ You can then use these options in your directives:

This test file contains enhanced metadata using custom extra options.

**Mapping JUnit XML ``<properties>`` to fields**

When importing JUnit XML files, ``tr_extra_options`` also controls which ``<property>``
elements inside ``<testcase>`` and ``<testsuite>`` blocks are surfaced as sphinx-needs
fields. Only property names listed here are imported; all others are silently ignored.

.. code-block:: xml

<!-- Example JUnit XML -->
<testcase name="test_login">
<properties>
<property name="priority" value="high"/>
<property name="verifies" value="REQ_001,REQ_002"/>
</properties>
</testcase>

.. code-block:: python

# conf.py – surface "priority" as a sphinx-needs field
tr_extra_options = ["priority"]
needs_extra_options = ["priority"]

See :ref:`tr_property_link_types` to map properties to sphinx-needs link fields instead.

.. _tr_property_link_types:

tr_property_link_types
----------------------
.. versionadded:: 1.3.0

Maps a JUnit XML ``<property>`` name to a sphinx-needs link field. When a test case or
suite contains a matching property, its comma-separated values are converted to
semicolon-separated need IDs and merged into the specified link field.

.. code-block:: python

# conf.py
tr_property_link_types = {
"verifies": "links", # comma-separated IDs → need links
}

With this configuration a ``<property name="verifies" value="REQ_001,REQ_002"/>`` in
a ``<testcase>`` results in ``links: REQ_001;REQ_002`` on the generated need.

Multiple properties can map to different link fields:

.. code-block:: python

tr_property_link_types = {
"verifies": "links",
"blocks": "blocks_back",
}

.. note::

The property name does **not** need to be listed in :ref:`tr_extra_options` when it
is used exclusively for link mapping. Only add it there if you also want the raw
value to appear as a plain text field on the need.

.. _tr_import_encoding:

tr_import_encoding
Expand Down
1 change: 1 addition & 0 deletions sphinxcontrib/test_reports/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@
"text",
"message",
"system-out",
"properties",
]
13 changes: 13 additions & 0 deletions sphinxcontrib/test_reports/directives/test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,19 @@ def run(self, nested=False, suite_count=-1, case_count=-1):
if case_parameter is None:
case_parameter = ""

# Flatten JUnit <properties> into top-level case keys so that
# the extra-data loop below picks them up as sphinx-needs fields.
# Only propagate properties that are explicitly listed in
# tr_extra_options to avoid unknown-kwarg errors from add_need.
# Properties do not overwrite core JUnit attributes (name, time, etc.).
allowed_extras = set(getattr(self.app.config, "tr_extra_options", []))
case_properties = case.get("properties", {})
for prop_name, prop_value in case_properties.items():
if prop_name in allowed_extras and prop_name not in case:
case[prop_name] = prop_value

self._apply_property_links(case_properties)

# Set extra data, which is not part of the Sphinx-Test-Reports default options
for key, value in case.items():
if key == "id" and value not in ["", None]:
Expand Down
19 changes: 19 additions & 0 deletions sphinxcontrib/test_reports/directives/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,22 @@ def prepare_basic_options(self):

# Also collect any extra options while we're at it
self.collect_extra_options()

def _apply_property_links(self, properties):
"""Map JUnit <properties> values to sphinx-needs link fields via tr_property_link_types."""
tr_property_link_types = getattr(self.app.config, "tr_property_link_types", {})
for prop_name, link_field in tr_property_link_types.items():
prop_value = properties.get(prop_name, "")
if prop_value:
link_ids = ";".join(
id_val.strip() for id_val in prop_value.split(",") if id_val.strip()
)
if link_field == "links":
existing = self.test_links
else:
existing = self.extra_options.get(link_field, "")
merged = existing + ";" + link_ids if existing else link_ids
if link_field == "links":
self.test_links = merged
else:
self.extra_options[link_field] = merged
16 changes: 16 additions & 0 deletions sphinxcontrib/test_reports/directives/test_suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ def run(self, nested=False, count=-1):
errors = suite["errors"]
failed = suite["failures"]

# Flatten JUnit <properties> into extra_options so that
# suite-level properties are surfaced as sphinx-needs fields.
# Only propagate properties that are explicitly listed in
# tr_extra_options to avoid unknown-kwarg errors from add_need.
allowed_extras = set(getattr(self.app.config, "tr_extra_options", []))
suite_properties = suite.get("properties", {})
for prop_name, prop_value in suite_properties.items():
if (
prop_name in allowed_extras
and prop_name not in suite
and prop_value not in ["", None]
):
self.extra_options[prop_name] = str(prop_value)

self._apply_property_links(suite_properties)

main_section = []
docname = self.state.document.settings.env.docname
main_section += add_need(
Expand Down
24 changes: 24 additions & 0 deletions sphinxcontrib/test_reports/junitparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ def parse_testcase(xml_object):
else:
tc_dict["system-out"] = ""

# Extract <properties> child elements as a dict
props = {}
if hasattr(testcase, "properties") and hasattr(
testcase.properties, "property"
):
for prop in testcase.properties.property:
name = prop.attrib.get("name", "")
value = prop.attrib.get("value", "")
if name:
props[name] = value
tc_dict["properties"] = props

return tc_dict

def parse_testsuite(xml_object):
Expand Down Expand Up @@ -112,6 +124,18 @@ def parse_testsuite(xml_object):
"testsuite_nested": [],
}

# Extract <properties> child elements as a dict
props = {}
if hasattr(testsuite, "properties") and hasattr(
testsuite.properties, "property"
):
for prop in testsuite.properties.property:
name = prop.attrib.get("name", "")
value = prop.attrib.get("value", "")
if name:
props[name] = value
ts_dict["properties"] = props

# add nested testsuite objects to
if hasattr(testsuite, "testsuite"):
for ts in testsuite.testsuite:
Expand Down
24 changes: 22 additions & 2 deletions sphinxcontrib/test_reports/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@

def _register_field(app, name, schema=None):
description = FIELD_DESCRIPTIONS.get(name, name)
_add_field(name, description, schema=schema)
try:
_add_field(name, description, schema=schema)
except Exception:
# Field already registered (e.g. via needs_fields or needs_extra_options
# in conf.py). Skip to avoid duplicate registration errors.
log = logging.getLogger(__name__)
log.debug(f"Field '{name}' already registered, skipping")

except ImportError:
from sphinx_needs.api import add_extra_option as _add_extra_option
Expand All @@ -78,7 +84,11 @@ def _register_field(app, name, schema=None):
kwargs["description"] = FIELD_DESCRIPTIONS.get(name, name)
if schema is not None:
kwargs["schema"] = schema
_add_extra_option(app, name, **kwargs)
try:
_add_extra_option(app, name, **kwargs)
except Exception:
log = logging.getLogger(__name__)
log.debug(f"Field '{name}' already registered, skipping")


def setup(app: Sphinx):
Expand Down Expand Up @@ -122,6 +132,7 @@ def setup(app: Sphinx):
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_property_link_types", {}, "env")

json_mapping = {
"json_config": {
Expand Down Expand Up @@ -258,6 +269,15 @@ def sphinx_needs_update(app: Sphinx, config: Config) -> None:
# https://sphinx-needs.readthedocs.io/en/latest/api.html#sphinx_needs.api.configuration.add_dynamic_function
add_dynamic_function(app, tr_link)

# Register tr_extra_options as sphinx-needs fields so that properties
# extracted from JUnit XML are accepted by sphinx-needs
tr_extra_options = getattr(config, "tr_extra_options", [])
for option_name in tr_extra_options:
if use_schema:
_register_field(app, option_name, schema={"type": "string"})
else:
_register_field(app, option_name)

# Extra need types
# For details about usage read
# https://sphinx-needs.readthedocs.io/en/latest/api.html#sphinx_needs.api.configuration.add_need_type
Expand Down
48 changes: 48 additions & 0 deletions tests/doc_test/properties_linking/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import os
import sys

import sphinx_needs
from packaging.version import Version

sys.path.insert(0, os.path.abspath("../../sphinxcontrib"))

extensions = ["sphinx_needs", "sphinxcontrib.test_reports"]

needs_types = [
{
"directive": "req",
"title": "Requirement",
"prefix": "REQ_",
"color": "#BFD8D2",
"style": "node",
},
]

source_suffix = ".rst"
master_doc = "index"

project = "properties-linking-test"
copyright = "2026, test"
author = "test"
version = "1.0"
release = "1.0"
language = "en"
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
html_theme = "alabaster"

# Register "verifies" as both a sphinx-needs field and a tr_extra_option
if Version(sphinx_needs.__version__) >= Version("8.0.0"):
needs_fields = {
"verifies": {"nullable": True},
"priority": {"nullable": True},
"category": {"nullable": True},
}
else:
needs_extra_options = ["verifies", "priority", "category"]

tr_extra_options = ["verifies", "priority", "category"]

# Map the "verifies" property to sphinx-needs links
tr_property_link_types = {
"verifies": "links",
}
32 changes: 32 additions & 0 deletions tests/doc_test/properties_linking/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
Properties Linking Test
=======================

Requirements
------------

.. req:: Authentication Login
:id: REQ_AUTH_001

.. req:: Authentication Session
:id: REQ_AUTH_002

.. req:: Authentication Invalid
:id: REQ_AUTH_003

.. req:: API Get Users
:id: REQ_API_010

.. req:: API List Users
:id: REQ_API_011

.. req:: API Filter Users
:id: REQ_API_012

Test Results
------------

.. test-file:: Auth and API Tests
:id: PROP_TF_1
:file: ../utils/xml_data_properties.xml
:auto_suites:
:auto_cases:
9 changes: 9 additions & 0 deletions tests/doc_test/utils/xml_data_empty_properties.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<testsuites>
<testsuite name="empty_props_suite" tests="2" failures="0" errors="0" skipped="0" time="1.0">
<properties/>
<testcase classname="tests.core" name="test_with_empty_properties" time="0.5">
<properties></properties>
</testcase>
<testcase classname="tests.core" name="test_without_properties_element" time="0.5"/>
</testsuite>
</testsuites>
34 changes: 34 additions & 0 deletions tests/doc_test/utils/xml_data_properties.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<testsuites>
<testsuite name="acceptance_tests" tests="4" failures="1" errors="0" skipped="1" time="12.345">
<properties>
<property name="environment" value="staging"/>
<property name="build_id" value="build-7742"/>
</properties>
<testcase classname="tests.auth" name="test_login_valid" time="1.200">
<properties>
<property name="verifies" value="REQ_AUTH_001,REQ_AUTH_002"/>
<property name="priority" value="high"/>
</properties>
</testcase>
<testcase classname="tests.auth" name="test_login_invalid" time="0.800">
<properties>
<property name="verifies" value="REQ_AUTH_003"/>
</properties>
</testcase>
<testcase classname="tests.auth" name="test_logout" time="0.500">
<failure type="AssertionError">Expected redirect to /login</failure>
</testcase>
<testcase classname="tests.auth" name="test_session_timeout" time="0.100">
<skipped type="skip" message="not yet implemented"/>
</testcase>
</testsuite>
<testsuite name="api_tests" tests="2" failures="0" errors="0" skipped="0" time="3.500">
<testcase classname="tests.api" name="test_get_users" time="2.000">
<properties>
<property name="verifies" value="REQ_API_010,REQ_API_011,REQ_API_012"/>
<property name="category" value="integration"/>
</properties>
</testcase>
<testcase classname="tests.api" name="test_create_user" time="1.500"/>
</testsuite>
</testsuites>
Loading
Loading