Skip to content
Closed
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
5 changes: 4 additions & 1 deletion src/tox/config/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,10 @@ def _add_provision_arguments(self, sub_parser: ToxParser) -> None: # noqa: PLR6
metavar="path",
of_type=Path,
default=None,
help="write a JSON file with detailed information about all commands and results involved",
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)"
),
)

class SeedAction(Action):
Expand Down
49 changes: 43 additions & 6 deletions src/tox/journal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,57 @@
"""This module handles collecting and persisting in json format a tox session."""
"""This module handles collecting and persisting test reports in various formats."""

from __future__ import annotations

import json
import locale
from pathlib import Path
from typing import TYPE_CHECKING

from .env import EnvJournal
from .main import Journal

if TYPE_CHECKING:
from tox.config.main import Config

def write_journal(path: Path | None, journal: Journal) -> None:

def write_journal(path: Path | None, journal: Journal, config: Config | None = None) -> None:
"""
Write journal to file using the configured format.

:param path: path to write the report to
:param journal: the journal containing test results
:param config: optional config to determine format (if None, uses JSON default)
"""
if path is None:
return
with Path(path).open("w", encoding=locale.getpreferredencoding(do_setlocale=False)) as file_handler:
json.dump(journal.content, file_handler, indent=2, ensure_ascii=False)

# Determine format from config or default to JSON
report_format: str | None = None
if config is not None:
try:
report_format = config.core["report_format"]
except KeyError:
report_format = None

# If no format specified, default to JSON (backward compatibility)
if report_format is None:
report_format = "json"

# Get formatter from registry
from tox.report.formatter import REGISTER # noqa: PLC0415

formatter = REGISTER.get(report_format)
if formatter is None:
# Fallback to JSON if format not found
from tox.report.formatters import JsonFormatter # noqa: PLC0415

formatter = JsonFormatter()

# Ensure output path has correct extension if it doesn't match formatter
output_path = Path(path)
if not output_path.suffix or output_path.suffix != formatter.file_extension:
output_path = output_path.with_suffix(formatter.file_extension)

# Format and write
formatter.format(journal, output_path)


__all__ = (
Expand Down
20 changes: 20 additions & 0 deletions src/tox/plugin/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from tox.config.cli.parser import ToxParser
from tox.config.sets import ConfigSet, EnvConfigSet
from tox.execute import Outcome
from tox.report.formatter import ReportFormatterRegister
from tox.session.state import State
from tox.tox_env.api import ToxEnv

Expand All @@ -52,6 +53,8 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
if inline is not None:
self.manager.register(inline)
self._load_external_plugins()
from tox.report import config as report_config # noqa: PLC0415

internal_plugins = (
loader_api,
provision,
Expand All @@ -70,6 +73,7 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
parallel,
sequential,
package_api,
report_config,
)
for plugin in internal_plugins:
self.manager.register(plugin)
Expand Down Expand Up @@ -111,12 +115,28 @@ def tox_on_install(self, tox_env: ToxEnv, arguments: Any, section: str, of_type:
def tox_env_teardown(self, tox_env: ToxEnv) -> None:
self.manager.hook.tox_env_teardown(tox_env=tox_env)

def tox_register_report_formatter(self, register: ReportFormatterRegister) -> None:
self.manager.hook.tox_register_report_formatter(register=register)

def _register_builtin_report_formatters(self) -> None:
"""Register built-in report formatters."""
from tox.report.formatter import REGISTER # noqa: PLC0415
from tox.report.formatters import JsonFormatter, XmlFormatter # noqa: PLC0415

# Register built-in formatters
REGISTER.register(JsonFormatter())
REGISTER.register(XmlFormatter())

# Allow plugins to register additional formatters
self.manager.hook.tox_register_report_formatter(register=REGISTER)

def load_plugins(self, path: Path) -> None:
for _plugin in self.manager.get_plugins(): # make sure we start with a clean state, repeated in memory run
self.manager.unregister(_plugin)
inline = _load_inline(path)
self._register_plugins(inline)
REGISTER._register_tox_env_types(self) # noqa: SLF001
self._register_builtin_report_formatters()


def _load_inline(path: Path) -> ModuleType | None: # used to be able to unregister plugin tests
Expand Down
11 changes: 11 additions & 0 deletions src/tox/plugin/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from tox.config.cli.parser import ToxParser
from tox.config.sets import ConfigSet, EnvConfigSet
from tox.execute import Outcome
from tox.report.formatter import ReportFormatterRegister
from tox.session.state import State
from tox.tox_env.api import ToxEnv
from tox.tox_env.register import ToxEnvRegister
Expand Down Expand Up @@ -122,6 +123,15 @@ def tox_env_teardown(tox_env: ToxEnv) -> None:
"""


@_spec
def tox_register_report_formatter(register: ReportFormatterRegister) -> None:
"""
Register a custom test report formatter.

:param register: a object that can be used to register new report formatters
"""


__all__ = [
"NAME",
"tox_add_core_config",
Expand All @@ -132,5 +142,6 @@ def tox_env_teardown(tox_env: ToxEnv) -> None:
"tox_env_teardown",
"tox_extend_envs",
"tox_on_install",
"tox_register_report_formatter",
"tox_register_tox_env",
]
5 changes: 5 additions & 0 deletions src/tox/report/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Report formatting system for tox."""

from __future__ import annotations

__all__ = ()
36 changes: 36 additions & 0 deletions src/tox/report/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Report format configuration."""

from __future__ import annotations

from typing import TYPE_CHECKING

from tox.plugin import impl

if TYPE_CHECKING:
from tox.config.sets import ConfigSet, EnvConfigSet
from tox.session.state import State


@impl
def tox_add_core_config(core_conf: ConfigSet, state: State) -> None: # noqa: ARG001
"""Add report_format configuration to core config."""
core_conf.add_config(
keys=["report_format"],
of_type=str | None,
default=None,
desc="Format for test reports (e.g., 'json', 'xml'). If None, uses default JSON format.",
)


@impl
def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: # noqa: ARG001
"""Add report_format configuration to environment config (inherits from core if not set)."""
env_conf.add_config(
keys=["report_format"],
of_type=str | None,
default=lambda conf, _env_name: conf.core["report_format"],
desc="Format for test reports for this environment (inherits from core config if not set).",
)


__all__ = ()
80 changes: 80 additions & 0 deletions src/tox/report/formatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Report formatter interface and registry."""

from __future__ import annotations

import abc
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from pathlib import Path

from tox.journal.main import Journal


class ReportFormatter(abc.ABC):
"""Base class for test report formatters."""

@property
@abc.abstractmethod
def name(self) -> str:
"""Return the name/identifier of this formatter (e.g., 'xml', 'json')."""

@property
@abc.abstractmethod
def file_extension(self) -> str:
"""Return the file extension for this format (e.g., '.xml', '.json')."""

@abc.abstractmethod
def format(self, journal: Journal, output_path: Path | None = None) -> str | None:
"""
Format the journal content and optionally write to file.

:param journal: the journal containing test results
:param output_path: optional path to write the formatted output to
:return: the formatted content as string, or None if written to file
"""
raise NotImplementedError


class ReportFormatterRegister:
"""Registry for report formatters."""

def __init__(self) -> None:
self._formatters: dict[str, ReportFormatter] = {}

def register(self, formatter: ReportFormatter) -> None:
"""
Register a report formatter.

:param formatter: the formatter to register
"""
if formatter.name in self._formatters:
msg = f"formatter with name '{formatter.name}' already registered"
raise ValueError(msg)
self._formatters[formatter.name] = formatter

def get(self, name: str) -> ReportFormatter | None:
"""
Get a formatter by name.

:param name: the formatter name
:return: the formatter or None if not found
"""
return self._formatters.get(name)

def list_formatters(self) -> list[str]:
"""
List all registered formatter names.

:return: list of formatter names
"""
return sorted(self._formatters.keys())


REGISTER = ReportFormatterRegister()

__all__ = (
"REGISTER",
"ReportFormatter",
"ReportFormatterRegister",
)
11 changes: 11 additions & 0 deletions src/tox/report/formatters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Built-in report formatters."""

from __future__ import annotations

from tox.report.formatters.json import JsonFormatter
from tox.report.formatters.xml import XmlFormatter

__all__ = (
"JsonFormatter",
"XmlFormatter",
)
39 changes: 39 additions & 0 deletions src/tox/report/formatters/json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""JSON report formatter."""

from __future__ import annotations

import json
import locale
from pathlib import Path
from typing import TYPE_CHECKING

from tox.report.formatter import ReportFormatter

if TYPE_CHECKING:
from tox.journal.main import Journal


class JsonFormatter(ReportFormatter):
"""JSON format report formatter."""

@property
def name(self) -> str:
return "json"

@property
def file_extension(self) -> str:
return ".json"

def format(self, journal: Journal, output_path: Path | None = None) -> str | None: # noqa: PLR6301
content = journal.content
json_content = json.dumps(content, indent=2, ensure_ascii=False)

if output_path is not None:
with Path(output_path).open("w", encoding=locale.getpreferredencoding(do_setlocale=False)) as file_handler:
file_handler.write(json_content)
return None

return json_content


__all__ = ("JsonFormatter",)
Loading
Loading