Skip to content

Commit bb17d11

Browse files
committed
Support for Custom Test Report Formats
1 parent 0cf41c1 commit bb17d11

File tree

11 files changed

+370
-7
lines changed

11 files changed

+370
-7
lines changed

src/tox/config/cli/parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def _add_provision_arguments(self, sub_parser: ToxParser) -> None: # noqa: PLR6
200200
metavar="path",
201201
of_type=Path,
202202
default=None,
203-
help="write a JSON file with detailed information about all commands and results involved",
203+
help="write a test report file with detailed information about all commands and results involved (format determined by report_format config or defaults to JSON)",
204204
)
205205

206206
class SeedAction(Action):

src/tox/journal/__init__.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,58 @@
1-
"""This module handles collecting and persisting in json format a tox session."""
1+
"""This module handles collecting and persisting test reports in various formats."""
22

33
from __future__ import annotations
44

5-
import json
65
import locale
76
from pathlib import Path
7+
from typing import TYPE_CHECKING
88

99
from .env import EnvJournal
1010
from .main import Journal
1111

12+
if TYPE_CHECKING:
13+
from tox.config.main import Config
1214

13-
def write_journal(path: Path | None, journal: Journal) -> None:
15+
16+
def write_journal(path: Path | None, journal: Journal, config: Config | None = None) -> None:
17+
"""
18+
Write journal to file using the configured format.
19+
20+
:param path: path to write the report to
21+
:param journal: the journal containing test results
22+
:param config: optional config to determine format (if None, uses JSON default)
23+
"""
1424
if path is None:
1525
return
16-
with Path(path).open("w", encoding=locale.getpreferredencoding(do_setlocale=False)) as file_handler:
17-
json.dump(journal.content, file_handler, indent=2, ensure_ascii=False)
26+
27+
# Determine format from config or default to JSON
28+
report_format: str | None = None
29+
if config is not None:
30+
try:
31+
report_format = config.core["report_format"]
32+
except KeyError:
33+
report_format = None
34+
35+
# If no format specified, default to JSON (backward compatibility)
36+
if report_format is None:
37+
report_format = "json"
38+
39+
# Get formatter from registry
40+
from tox.report.formatter import REGISTER # noqa: PLC0415
41+
42+
formatter = REGISTER.get(report_format)
43+
if formatter is None:
44+
# Fallback to JSON if format not found
45+
from tox.report.formatters import JsonFormatter # noqa: PLC0415
46+
47+
formatter = JsonFormatter()
48+
49+
# Ensure output path has correct extension if it doesn't match formatter
50+
output_path = Path(path)
51+
if not output_path.suffix or output_path.suffix != formatter.file_extension:
52+
output_path = output_path.with_suffix(formatter.file_extension)
53+
54+
# Format and write
55+
formatter.format(journal, output_path)
1856

1957

2058
__all__ = (

src/tox/plugin/manager.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from tox.config.cli.parser import ToxParser
2727
from tox.config.sets import ConfigSet, EnvConfigSet
2828
from tox.execute import Outcome
29+
from tox.report.formatter import ReportFormatterRegister
2930
from tox.session.state import State
3031
from tox.tox_env.api import ToxEnv
3132

@@ -52,6 +53,8 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
5253
if inline is not None:
5354
self.manager.register(inline)
5455
self._load_external_plugins()
56+
from tox.report import config as report_config # noqa: PLC0415
57+
5558
internal_plugins = (
5659
loader_api,
5760
provision,
@@ -70,6 +73,7 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
7073
parallel,
7174
sequential,
7275
package_api,
76+
report_config,
7377
)
7478
for plugin in internal_plugins:
7579
self.manager.register(plugin)
@@ -111,12 +115,28 @@ def tox_on_install(self, tox_env: ToxEnv, arguments: Any, section: str, of_type:
111115
def tox_env_teardown(self, tox_env: ToxEnv) -> None:
112116
self.manager.hook.tox_env_teardown(tox_env=tox_env)
113117

118+
def tox_register_report_formatter(self, register: ReportFormatterRegister) -> None:
119+
self.manager.hook.tox_register_report_formatter(register=register)
120+
121+
def _register_builtin_report_formatters(self) -> None:
122+
"""Register built-in report formatters."""
123+
from tox.report.formatter import REGISTER # noqa: PLC0415
124+
from tox.report.formatters import JsonFormatter, XmlFormatter # noqa: PLC0415
125+
126+
# Register built-in formatters
127+
REGISTER.register(JsonFormatter())
128+
REGISTER.register(XmlFormatter())
129+
130+
# Allow plugins to register additional formatters
131+
self.manager.hook.tox_register_report_formatter(register=REGISTER)
132+
114133
def load_plugins(self, path: Path) -> None:
115134
for _plugin in self.manager.get_plugins(): # make sure we start with a clean state, repeated in memory run
116135
self.manager.unregister(_plugin)
117136
inline = _load_inline(path)
118137
self._register_plugins(inline)
119138
REGISTER._register_tox_env_types(self) # noqa: SLF001
139+
self._register_builtin_report_formatters()
120140

121141

122142
def _load_inline(path: Path) -> ModuleType | None: # used to be able to unregister plugin tests

src/tox/plugin/spec.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from tox.config.cli.parser import ToxParser
1313
from tox.config.sets import ConfigSet, EnvConfigSet
1414
from tox.execute import Outcome
15+
from tox.report.formatter import ReportFormatterRegister
1516
from tox.session.state import State
1617
from tox.tox_env.api import ToxEnv
1718
from tox.tox_env.register import ToxEnvRegister
@@ -122,6 +123,15 @@ def tox_env_teardown(tox_env: ToxEnv) -> None:
122123
"""
123124

124125

126+
@_spec
127+
def tox_register_report_formatter(register: ReportFormatterRegister) -> None:
128+
"""
129+
Register a custom test report formatter.
130+
131+
:param register: a object that can be used to register new report formatters
132+
"""
133+
134+
125135
__all__ = [
126136
"NAME",
127137
"tox_add_core_config",
@@ -133,4 +143,5 @@ def tox_env_teardown(tox_env: ToxEnv) -> None:
133143
"tox_extend_envs",
134144
"tox_on_install",
135145
"tox_register_tox_env",
146+
"tox_register_report_formatter",
136147
]

src/tox/report/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Report formatting system for tox."""
2+
3+
__all__ = ()
4+

src/tox/report/config.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Report format configuration."""
2+
3+
from __future__ import annotations
4+
5+
from tox.plugin import impl
6+
from tox.report.formatter import REGISTER
7+
8+
9+
@impl
10+
def tox_add_core_config(core_conf, state): # noqa: ARG001
11+
"""Add report_format configuration to core config."""
12+
core_conf.add_config(
13+
keys=["report_format"],
14+
of_type=str | None,
15+
default=None,
16+
desc="Format for test reports (e.g., 'json', 'xml'). If None, uses default JSON format.",
17+
)
18+
19+
20+
@impl
21+
def tox_add_env_config(env_conf, state): # noqa: ARG001
22+
"""Add report_format configuration to environment config (inherits from core if not set)."""
23+
env_conf.add_config(
24+
keys=["report_format"],
25+
of_type=str | None,
26+
default=lambda conf, env_name: conf.core["report_format"],
27+
desc="Format for test reports for this environment (inherits from core config if not set).",
28+
)
29+
30+
31+
__all__ = ()
32+

src/tox/report/formatter.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Report formatter interface and registry."""
2+
3+
from __future__ import annotations
4+
5+
import abc
6+
from typing import TYPE_CHECKING, Any
7+
8+
from tox.journal.main import Journal
9+
10+
if TYPE_CHECKING:
11+
from pathlib import Path
12+
13+
14+
class ReportFormatter(abc.ABC):
15+
"""Base class for test report formatters."""
16+
17+
@property
18+
@abc.abstractmethod
19+
def name(self) -> str:
20+
"""Return the name/identifier of this formatter (e.g., 'xml', 'json')."""
21+
22+
@property
23+
@abc.abstractmethod
24+
def file_extension(self) -> str:
25+
"""Return the file extension for this format (e.g., '.xml', '.json')."""
26+
27+
@abc.abstractmethod
28+
def format(self, journal: Journal, output_path: Path | None = None) -> str | None:
29+
"""
30+
Format the journal content and optionally write to file.
31+
32+
:param journal: the journal containing test results
33+
:param output_path: optional path to write the formatted output to
34+
:return: the formatted content as string, or None if written to file
35+
"""
36+
raise NotImplementedError
37+
38+
39+
class ReportFormatterRegister:
40+
"""Registry for report formatters."""
41+
42+
def __init__(self) -> None:
43+
self._formatters: dict[str, ReportFormatter] = {}
44+
45+
def register(self, formatter: ReportFormatter) -> None:
46+
"""
47+
Register a report formatter.
48+
49+
:param formatter: the formatter to register
50+
"""
51+
if formatter.name in self._formatters:
52+
msg = f"formatter with name '{formatter.name}' already registered"
53+
raise ValueError(msg)
54+
self._formatters[formatter.name] = formatter
55+
56+
def get(self, name: str) -> ReportFormatter | None:
57+
"""
58+
Get a formatter by name.
59+
60+
:param name: the formatter name
61+
:return: the formatter or None if not found
62+
"""
63+
return self._formatters.get(name)
64+
65+
def list_formatters(self) -> list[str]:
66+
"""
67+
List all registered formatter names.
68+
69+
:return: list of formatter names
70+
"""
71+
return sorted(self._formatters.keys())
72+
73+
74+
REGISTER = ReportFormatterRegister()
75+
76+
__all__ = (
77+
"ReportFormatter",
78+
"ReportFormatterRegister",
79+
"REGISTER",
80+
)
81+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Built-in report formatters."""
2+
3+
from __future__ import annotations
4+
5+
from tox.report.formatters.json import JsonFormatter
6+
from tox.report.formatters.xml import XmlFormatter
7+
8+
__all__ = (
9+
"JsonFormatter",
10+
"XmlFormatter",
11+
)
12+

src/tox/report/formatters/json.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""JSON report formatter."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import locale
7+
from pathlib import Path
8+
9+
from tox.journal.main import Journal
10+
from tox.report.formatter import ReportFormatter
11+
12+
13+
class JsonFormatter(ReportFormatter):
14+
"""JSON format report formatter."""
15+
16+
@property
17+
def name(self) -> str:
18+
return "json"
19+
20+
@property
21+
def file_extension(self) -> str:
22+
return ".json"
23+
24+
def format(self, journal: Journal, output_path: Path | None = None) -> str | None:
25+
content = journal.content
26+
json_content = json.dumps(content, indent=2, ensure_ascii=False)
27+
28+
if output_path is not None:
29+
with Path(output_path).open("w", encoding=locale.getpreferredencoding(do_setlocale=False)) as file_handler:
30+
file_handler.write(json_content)
31+
return None
32+
33+
return json_content
34+
35+
36+
__all__ = ("JsonFormatter",)
37+

0 commit comments

Comments
 (0)