diff --git a/docs/api/pytest-plugin.md b/docs/api/pytest-plugin.md index f0ad8051..6b72c799 100644 --- a/docs/api/pytest-plugin.md +++ b/docs/api/pytest-plugin.md @@ -46,4 +46,3 @@ hook to reconcile per-worker Result counts. See - deserialize_trial_specs - finalize_worker - handle_testnodedown - - discover_sinks_from_conftest diff --git a/docs/concepts/overview.md b/docs/concepts/overview.md index 2fa654aa..f0eec123 100644 --- a/docs/concepts/overview.md +++ b/docs/concepts/overview.md @@ -125,7 +125,7 @@ RAMPART registers as a pytest plugin automatically when installed. It provides: - **Markers**: `@pytest.mark.harm(...)` for categorization, `@pytest.mark.trial(n=...)` for statistical repetition - **Automatic result collection**: Results from `Attacks.*` and `Probes.*` are collected without manual wiring - **Terminal summary**: A safety summary printed after the standard pytest output -- **Report sinks**: Structured output via the `pytest_rampart_sinks` hook (the `rampart_sinks` fixture is deprecated) +- **Report sinks**: Structured output via the `pytest_rampart_sinks` hook See [pytest Markers & Fixtures](../usage/pytest-integration.md) for setup details. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 08b7541b..70f5d339 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -135,7 +135,7 @@ See [pytest Markers & Fixtures](../usage/pytest-integration.md) for the full mar ## Step 4: Add Reporting -Register report sinks with the `pytest_rampart_sinks` hook in your `conftest.py` so RAMPART writes structured JSON reports. See [pytest Markers & Fixtures](../usage/pytest-integration.md#pytest_rampart_sinks-hook) for the setup. (The older `rampart_sinks` fixture is still supported but deprecated.) +Register report sinks with the `pytest_rampart_sinks` hook in your `conftest.py` so RAMPART writes structured JSON reports. See [pytest Markers & Fixtures](../usage/pytest-integration.md#pytest_rampart_sinks-hook) for the setup. --- diff --git a/docs/usage/ci-integration.md b/docs/usage/ci-integration.md index 2214bfa2..52d77aa5 100644 --- a/docs/usage/ci-integration.md +++ b/docs/usage/ci-integration.md @@ -68,9 +68,6 @@ def pytest_rampart_sinks(config): The JSON file contains aggregate statistics and per-result data that CI dashboards can consume. The hook is resolved on the controller, so it behaves identically in single-process and [`pytest-xdist`](xdist.md) CI runs. See [Registering Sinks](pytest-integration.md#pytest_rampart_sinks-hook). -!!! warning "Deprecated" - The older `rampart_sinks` fixture still works but is deprecated and will be removed in `0.3.0`. Prefer the `pytest_rampart_sinks` hook above. - --- ## Pytest Options diff --git a/docs/usage/pytest-integration.md b/docs/usage/pytest-integration.md index b4eb3c81..ffdc5e1b 100644 --- a/docs/usage/pytest-integration.md +++ b/docs/usage/pytest-integration.md @@ -73,60 +73,13 @@ async def test_with_threshold(adapter): --- -## Fixtures - -### `rampart_sinks` - -!!! warning "Deprecated" - The `rampart_sinks` fixture is deprecated and will be removed in `0.3.0`. - Use the [`pytest_rampart_sinks` hook](#pytest_rampart_sinks-hook) instead — it - behaves identically in single-process and `pytest-xdist` runs and accepts the - active `pytest.Config`. Defining the fixture now emits a `DeprecationWarning`. - -Define this **session-scoped** fixture in your `conftest.py` to configure report output: - -```python -from pathlib import Path -import pytest -from rampart.reporting import JsonFileReportSink, ReportSink - - -@pytest.fixture(scope="session") -def rampart_sinks() -> list[ReportSink]: - return [JsonFileReportSink(output_dir=Path(".report"))] -``` - -If you don't define this fixture, RAMPART still prints the terminal summary — but no structured report files are written. You can provide multiple sinks: - -```python -@pytest.fixture(scope="session") -def rampart_sinks() -> list[ReportSink]: - return [ - JsonFileReportSink(output_dir=Path(".report")), - MyCustomDatabaseSink(connection_string="..."), - ] -``` - -!!! warning "xdist compatibility" - Under [`pytest-xdist`](xdist.md), the controller process discovers fixture-based sinks by calling `rampart_sinks` directly. Fixtures that depend on other fixtures (e.g., `tmp_path_factory`, `request`) cannot be resolved on the controller and are skipped with a warning. Use a parameterless fixture or a module-level list to remain compatible: - - ```python - # Resolved on the xdist controller (controller-only — single-process - # discovery needs the fixture form above, or the hook below) - rampart_sinks = [JsonFileReportSink(output_dir=Path(".report"))] - ``` - - For sinks that need configuration or dependencies, prefer the - `pytest_rampart_sinks` hook below — it is resolved on the controller and works - identically in single-process and parallel runs. - ---- +## Registering Sinks ### `pytest_rampart_sinks` hook -For sinks that need configuration — or to register sinks in a way that behaves -identically in single-process and `pytest-xdist` runs — implement the -`pytest_rampart_sinks` hook in your `conftest.py`: +Implement the `pytest_rampart_sinks` hook in your `conftest.py` to register the +report sinks RAMPART emits to. It behaves identically in single-process and +`pytest-xdist` runs: ```python # conftest.py @@ -143,10 +96,8 @@ The hook receives the active `pytest.Config`, so you can build sinks from CLI/ini options or environment variables. Multiple implementations are supported; RAMPART emits to the **union** of every returned sink. -**Precedence:** when any `pytest_rampart_sinks` implementation exists, it is -authoritative and the `rampart_sinks` fixture path is skipped entirely (so a -project that defines both does not double-register). The fixture remains the -single-process fallback when no hook implementation is present. +If you don't register any sinks, RAMPART still prints the terminal summary — but +no structured report files are written. --- diff --git a/docs/usage/results-and-reporting.md b/docs/usage/results-and-reporting.md index 7750afe0..5c1a5630 100644 --- a/docs/usage/results-and-reporting.md +++ b/docs/usage/results-and-reporting.md @@ -111,7 +111,7 @@ class MyDatabaseSink: Register the `pytest_rampart_sinks` hook in your `conftest.py`. See [pytest Markers & Fixtures](pytest-integration.md#pytest_rampart_sinks-hook) for the setup and examples with multiple sinks. !!! note "Parallel execution" - Under [`pytest-xdist`](xdist.md), workers send their results to the controller, which emits sinks **once** with a unified [`TestRunReport`][rampart.reporting.sink.TestRunReport]. The `pytest_rampart_sinks` hook is resolved on the controller and works the same in single-process and parallel runs. The deprecated `rampart_sinks` fixture is still supported as a single-process fallback, but on the controller it cannot depend on other fixtures. See [Registering Sinks](xdist.md#registering-sinks-the-pytest_rampart_sinks-hook) for details. + Under [`pytest-xdist`](xdist.md), workers send their results to the controller, which emits sinks **once** with a unified [`TestRunReport`][rampart.reporting.sink.TestRunReport]. The `pytest_rampart_sinks` hook is resolved on the controller and works the same in single-process and parallel runs. See [Registering Sinks](xdist.md#registering-sinks-the-pytest_rampart_sinks-hook) for details. --- diff --git a/docs/usage/xdist.md b/docs/usage/xdist.md index d38770d8..c0f81d1c 100644 --- a/docs/usage/xdist.md +++ b/docs/usage/xdist.md @@ -131,57 +131,9 @@ def pytest_rampart_sinks(config): - Non-`ReportSink` items (or a non-list return) are dropped with a warning, so one malformed implementation cannot break emission. -### Precedence vs the `rampart_sinks` fixture - -!!! warning "Deprecated" - The `rampart_sinks` fixture is deprecated and will be removed in `0.3.0`. - Prefer the `pytest_rampart_sinks` hook above. Resolving the fixture emits a - `DeprecationWarning` in both single-process and controller discovery. - -The legacy `rampart_sinks` fixture is still supported as a **single-process -fallback**. The rule is: - -- If **any** `pytest_rampart_sinks` hook implementation exists, the hook is - authoritative and the fixture path is skipped entirely (so a project that - defines both does **not** double-register). -- If **no** hook implementation exists, RAMPART falls back to the fixture. On the - xdist controller this fallback scans registered conftest modules for a - `rampart_sinks` attribute. - -### Fixture fallback constraints (no hook present) - -When you rely on the fixture fallback under xdist, pytest's fixture machinery -does not run on the controller. RAMPART therefore unwraps a **parameterless** -`rampart_sinks` fixture and calls its underlying function directly, so these -shapes resolve: - -```python -# Parameterless session fixture — resolves single-process AND on the -# xdist controller. -@pytest.fixture(scope="session") -def rampart_sinks(): - return [JsonFileReportSink(output_dir=Path(".report"))] - -# Plain list assigned at module level — resolved on the xdist controller -# only. Single-process discovery looks up a *fixture* named rampart_sinks, -# so a bare module-level list is silently ignored there; use the fixture -# form above (or the hook) for single-process runs. -rampart_sinks = [JsonFileReportSink(output_dir=Path(".report"))] -``` - -A **fixture with dependencies** cannot be resolved on the controller and is -skipped with a warning: - -```python -# Not resolvable on the controller — use the hook instead -@pytest.fixture(scope="session") -def rampart_sinks(my_sink_config, db_connection): - return [DatabaseSink(connection=db_connection)] -``` - -If your sinks need dependencies, **use the `pytest_rampart_sinks` hook** — it -receives the `pytest.Config` and runs on the controller, so you can build sinks -from `config` values or environment variables there. +If your sinks need dependencies, build them inside the hook — it receives the +`pytest.Config` and runs on the controller, so you can build sinks from `config` +values or environment variables there. --- @@ -259,9 +211,6 @@ does not discard normal Results from that worker. ## Limitations -- Sinks discovered through the **fixture fallback** on the controller cannot depend - on other pytest fixtures — use the `pytest_rampart_sinks` hook instead (see - [Registering Sinks](#registering-sinks-the-pytest_rampart_sinks-hook)). - Results recorded only during fixture teardown are outside the report-streaming boundary and are not included. - A worker that dies can lose Results whose eligible reports had not reached diff --git a/rampart/common/deprecation.py b/rampart/common/deprecation.py deleted file mode 100644 index 627e0262..00000000 --- a/rampart/common/deprecation.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Helpers for emitting consistent deprecation warnings.""" - -from __future__ import annotations - -import warnings -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Callable - - -def emit_deprecation_warning( - *, - old_item: type | Callable[..., Any] | str, - new_item: type | Callable[..., Any] | str, - removed_in: str, -) -> None: - """Emit a ``DeprecationWarning`` from a deprecated item to its replacement. - - Args: - old_item (type | Callable[..., Any] | str): The deprecated class, - function, or its string name. - new_item (type | Callable[..., Any] | str): The replacement class, - function, or its string name. - removed_in (str): The release in which ``old_item`` will be removed. - """ - old_name = _qualified_name(item=old_item) - new_name = _qualified_name(item=new_item) - warnings.warn( - f"{old_name} is deprecated and will be removed in {removed_in}. " - f"Use {new_name} instead.", - DeprecationWarning, - stacklevel=3, - ) - - -def _qualified_name(*, item: type | Callable[..., Any] | str) -> str: - """Return a printable name for a class, callable, or string label. - - Args: - item (type | Callable[..., Any] | str): The item to describe. - - Returns: - str: ``module.qualname`` for classes and callables, the string itself - for string labels, or ``repr(item)`` as a last resort. - """ - if isinstance(item, str): - return item - module = getattr(item, "__module__", None) - qualname = getattr(item, "__qualname__", None) - if module and qualname: - return f"{module}.{qualname}" - return repr(item) diff --git a/rampart/pytest_plugin/_hooks.py b/rampart/pytest_plugin/_hooks.py index 935946b8..8ab9641c 100644 --- a/rampart/pytest_plugin/_hooks.py +++ b/rampart/pytest_plugin/_hooks.py @@ -27,8 +27,8 @@ def pytest_rampart_sinks(config: pytest.Config) -> list[ReportSink]: # ruff: ig Implement this hook in your ``conftest.py`` to register sinks in a way that works identically in single-process and ``pytest-xdist`` - runs. Unlike the ``rampart_sinks`` fixture, hook implementations are - resolved on the xdist controller, which never executes fixtures. + runs. Hook implementations are resolved on the xdist controller, + which never executes fixtures. Multiple implementations are supported; RAMPART emits to the union of every returned sink. diff --git a/rampart/pytest_plugin/_xdist.py b/rampart/pytest_plugin/_xdist.py index 329b2752..5746b975 100644 --- a/rampart/pytest_plugin/_xdist.py +++ b/rampart/pytest_plugin/_xdist.py @@ -23,7 +23,6 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, cast -from rampart.common.deprecation import emit_deprecation_warning from rampart.common.text import safe_float, safe_str, safe_str_list from rampart.common.text import strip_ansi as _strip_ansi_impl from rampart.core.result import ( @@ -45,11 +44,8 @@ Turn, ) from rampart.pytest_plugin._session import TrialSpec -from rampart.reporting.sink import ReportSink if TYPE_CHECKING: - from collections.abc import Callable - import pytest from _typeshed import ConvertibleToInt @@ -1538,165 +1534,3 @@ def handle_testnodedown( f"(expected {expected_result_count}, received {received_result_count})" ), ) - - -def discover_sinks_from_conftest(*, config: pytest.Config) -> list[ReportSink]: - """Discover ``rampart_sinks`` definitions from registered conftest modules. - - Workers run the standard ``_rampart_sink_bootstrap`` fixture to - register sinks via pytest's fixture machinery. The controller has - no test execution, so fixtures do not run. This function scans - registered plugins for a module-level ``rampart_sinks`` attribute - and resolves it: - - - If callable with zero arguments, invoke it and use the return. - - If a list, use it directly. - - Otherwise, log a warning and skip. - - Sinks that depend on other fixtures cannot be discovered this way. - Such configurations should register sinks via the - ``pytest_rampart_sinks`` hook, which is resolved identically on the - controller and in every worker. - - Args: - config (pytest.Config): The pytest configuration object. - - Returns: - list[ReportSink]: Discovered sinks (may be empty). - """ - discovered: list[ReportSink] = [] - seen: set[int] = set() - for plugin in config.pluginmanager.get_plugins(): - if plugin is None or id(plugin) in seen: - continue - seen.add(id(plugin)) - candidate = getattr(plugin, "rampart_sinks", None) - if candidate is None: - continue - resolved = _resolve_sink_candidate(candidate=candidate, plugin=plugin) - if resolved is None: - continue - for sink in resolved: - if isinstance(sink, ReportSink): - discovered.append(sink) - else: - logger.warning( - "rampart_sinks in %s yielded a non-ReportSink: %r", - getattr(plugin, "__name__", repr(plugin)), - sink, - ) - return discovered - - -def _unwrap_fixture_function(candidate: object) -> Callable[..., object] | None: - """Return the underlying function of a ``@pytest.fixture``-wrapped object. - - pytest >= 8.4 wraps fixtures in a ``FixtureFunctionDefinition`` whose - ``inspect.isfunction`` is False; the real function is reachable via - ``_get_wrapped_function()`` (with ``_fixture_function`` / ``__wrapped__`` - as fallbacks). Returns the recovered function, or None when - ``candidate`` is not a fixture wrapper we can unwrap. - """ - import inspect # ruff: ignore[import-outside-top-level] - - getter = getattr(candidate, "_get_wrapped_function", None) - if callable(getter): - try: - wrapped = getter() - except Exception: # ruff: ignore[blind-except] — defensive across pytest versions - wrapped = None - if inspect.isfunction(wrapped): - return wrapped - for attr in ("_fixture_function", "__wrapped__"): - wrapped = getattr(candidate, attr, None) - if inspect.isfunction(wrapped): - return wrapped - return None - - -def _resolve_sink_candidate( - *, - candidate: object, - plugin: object, -) -> list[object] | None: - """Resolve a module-level ``rampart_sinks`` attribute into a list of sinks. - - Handles three shapes: - - - A list — used directly. - - A zero-argument plain function — called, and its list return used. - - A ``@pytest.fixture``-wrapped *parameterless* function — unwrapped to - its underlying function and called directly (no pytest fixture - machinery), so the documented session-fixture fallback keeps working - on the xdist controller. - - Any other shape — a fixture that depends on other fixtures, a callable - requiring arguments, or a non-list return — is skipped with a warning - pointing at the ``pytest_rampart_sinks`` hook, which works identically - on the controller and in every worker. - - Returns: - None on failure (logged) so the caller can continue scanning other plugins. - - Raises: - KeyboardInterrupt: If the function is interrupted by the user. - SystemExit: If the function attempts to exit the program. - """ - import inspect # ruff: ignore[import-outside-top-level] - - plugin_name = getattr(plugin, "__name__", repr(plugin)) - if isinstance(candidate, list): - return cast("list[object]", candidate) - - func: Callable[..., object] | None - if inspect.isfunction(candidate): - func = candidate - else: - func = _unwrap_fixture_function(candidate) - if func is not None: - emit_deprecation_warning( - old_item="The rampart_sinks fixture", - new_item="the pytest_rampart_sinks hook", - removed_in="0.3.0", - ) - if func is None: - logger.warning( - "rampart_sinks in %s is %s, which controller-side discovery " - "cannot resolve. Register sinks via the pytest_rampart_sinks " - "hook instead.", - plugin_name, - type(candidate).__name__, - ) - return None - - sig = inspect.signature(func) - if len(sig.parameters) > 0: - logger.warning( - "rampart_sinks in %s requires arguments (%s); controller-side " - "discovery cannot satisfy those. Use the pytest_rampart_sinks " - "hook, or provide a parameterless function or a list.", - plugin_name, - list(sig.parameters), - ) - return None - - try: - value = func() - except (KeyboardInterrupt, SystemExit): - raise - except Exception as exc: # ruff: ignore[blind-except] — broad on purpose: user code - logger.warning( - "rampart_sinks in %s raised during controller-side discovery: %s", - plugin_name, - exc, - ) - return None - - if isinstance(value, list): - return cast("list[object]", value) - logger.warning( - "rampart_sinks in %s returned %s instead of list[ReportSink].", - plugin_name, - type(value).__name__, - ) - return None diff --git a/rampart/pytest_plugin/plugin.py b/rampart/pytest_plugin/plugin.py index 617006ee..5faedd25 100644 --- a/rampart/pytest_plugin/plugin.py +++ b/rampart/pytest_plugin/plugin.py @@ -27,7 +27,6 @@ import pytest -from rampart.common.deprecation import emit_deprecation_warning from rampart.common.text import strip_ansi from rampart.core.execution import ( ExecutionEventHandler, @@ -49,7 +48,6 @@ SIZE_LIMIT_OPTION, WorkerOutputError, attach_report_results, - discover_sinks_from_conftest, finalize_worker, get_dist_mode, get_worker_count, @@ -214,7 +212,7 @@ def pytest_configure(config: pytest.Config) -> None: """Register RAMPART markers and install default handler factory. Initializes session. Sinks are provided by teams via the - ``rampart_sinks`` fixture in their conftest.py, not through + ``pytest_rampart_sinks`` hook in their conftest.py, not through configuration. Args: @@ -555,25 +553,6 @@ def pytest_runtest_logreport(report: pytest.TestReport) -> None: received_counts[worker_id] = received_counts.get(worker_id, 0) + result_count -def _has_sink_hook_impl(*, config: pytest.Config) -> bool: - """Return True if any plugin implements ``pytest_rampart_sinks``. - - Keyed on implementation existence, not on the sinks returned: a hook - implementation may legitimately contribute zero sinks, and that must - still suppress the legacy fixture fallback. - - Args: - config (pytest.Config): The pytest configuration object. - - Returns: - bool: True if at least one ``pytest_rampart_sinks`` impl exists. - """ - hook = getattr(config.pluginmanager.hook, "pytest_rampart_sinks", None) - if hook is None: - return False - return bool(hook.get_hookimpls()) - - def _resolve_hook_sinks(*, config: pytest.Config) -> list[ReportSink]: """Collect and validate sinks from the ``pytest_rampart_sinks`` hook. @@ -611,83 +590,6 @@ def _resolve_hook_sinks(*, config: pytest.Config) -> list[ReportSink]: return sinks -@pytest.fixture(scope="session", autouse=True) -def _rampart_sink_bootstrap( # pytest discovers this via autouse=True - request: pytest.FixtureRequest, -) -> None: - """Merge team-provided sinks into the RAMPART session. - - If the consuming project defines a ``rampart_sinks`` fixture - (session-scoped, returning ``list[ReportSink]``), this fixture - picks it up and registers those sinks for report emission at - session end. - - Example in a team's conftest.py: - - ```python - @pytest.fixture(scope="session") - def rampart_sinks(): - return [JsonFileReportSink(output_dir=Path(".report"))] - ``` - - Precedence: when a ``pytest_rampart_sinks`` hook implementation - exists it is authoritative and this fixture-based path is skipped - entirely (hook sinks are registered at session finish), so a project - that defines both does not double-register. - - Under pytest-xdist, this fixture is a no-op on worker processes - (workers skip sink emission entirely); sink discovery on the - controller is handled by the ``pytest_rampart_sinks`` hook or, as a - fallback, ``_xdist.discover_sinks_from_conftest``. - - No test author ever imports or references this fixture. - """ - if is_xdist_worker(config=request.config): - return - - if _has_sink_hook_impl(config=request.config): - return - - rampart_session = request.config.stash.get(_rampart_key, None) - if rampart_session is None: - return - - try: - user_sinks = request.getfixturevalue("rampart_sinks") - except pytest.FixtureLookupError: - return - - emit_deprecation_warning( - old_item="The rampart_sinks fixture", - new_item="the pytest_rampart_sinks hook", - removed_in="0.3.0", - ) - - if not isinstance(user_sinks, list): - logger.warning( - "rampart_sinks fixture must return list[ReportSink], got %s. Ignoring.", - type(user_sinks).__name__, - ) - return - - user_sinks = cast("list[object]", user_sinks) - - if not all(isinstance(x, ReportSink) for x in user_sinks): - logger.warning( - "rampart_sinks fixture must return list[ReportSink], " - "got list with non-ReportSink items. Ignoring.", - ) - return - - user_sinks = cast("list[ReportSink]", user_sinks) - - rampart_session.add_sinks(sinks=user_sinks) - logger.info( - "Loaded %d sink(s) from rampart_sinks fixture.", - len(user_sinks), - ) - - def _aggregate_trial_results( *, rampart_session: RampartSession, @@ -796,15 +698,16 @@ def pytest_sessionfinish( and skip sink emission (Results already streamed on test reports). - xdist controller: trials already aggregated against the merged ``_results_by_nodeid``; resolve sinks via the - ``pytest_rampart_sinks`` hook (falling back to conftest discovery), - evaluate gates, and emit. + ``pytest_rampart_sinks`` hook, evaluate gates, and emit. - non-xdist: original single-process pipeline (aggregate, gate, - emit); hook sinks are added here when the fixture path was - suppressed. + emit); hook sinks are added here. An incomplete run (a lost or crashed worker) is forced to a non-zero exit status so a dropped shard cannot pass silently. + A ``--collect-only`` run skips aggregation and sink emission entirely, + so collection never overwrites report files. + Args: session (pytest.Session): The pytest session. exitstatus (int): The session exit status. @@ -813,6 +716,9 @@ def pytest_sessionfinish( if rampart_session is None: return + if session.config.getoption("collectonly", default=False): + return + start_time = session.config.stash.get(_session_start_key, None) if start_time is not None: rampart_session.set_duration(duration_seconds=time.monotonic() - start_time) @@ -835,17 +741,13 @@ def pytest_sessionfinish( if is_xdist_controller(config=session.config): _record_xdist_metadata(session=session, rampart_session=rampart_session) - if _has_sink_hook_impl(config=session.config): - controller_sinks = _resolve_hook_sinks(config=session.config) - else: - controller_sinks = discover_sinks_from_conftest(config=session.config) + controller_sinks = _resolve_hook_sinks(config=session.config) if controller_sinks: rampart_session.add_sinks(sinks=controller_sinks) _emit_sinks(rampart_session=rampart_session) return - if _has_sink_hook_impl(config=session.config): - rampart_session.add_sinks(sinks=_resolve_hook_sinks(config=session.config)) + rampart_session.add_sinks(sinks=_resolve_hook_sinks(config=session.config)) _emit_sinks(rampart_session=rampart_session) diff --git a/rampart/reporting/json_file.py b/rampart/reporting/json_file.py index 5bf576ec..2939fc9e 100644 --- a/rampart/reporting/json_file.py +++ b/rampart/reporting/json_file.py @@ -8,20 +8,12 @@ built-in ``ReportSink`` for teams that want local file output without building a custom sink. -Teams wire it up in their conftest, either via the -``pytest_rampart_sinks`` hook (recommended; works under ``pytest-xdist``) -or the legacy ``rampart_sinks`` fixture: +Teams wire it up in their conftest via the ``pytest_rampart_sinks`` +hook (works under ``pytest-xdist``): ```python -# Recommended: hook, resolved on the xdist controller def pytest_rampart_sinks(config): return [JsonFileReportSink(output_dir=Path(".report"))] - - -# Legacy fixture (single-process fallback) -@pytest.fixture(scope="session") -def rampart_sinks(): - return [JsonFileReportSink(output_dir=Path(".report"))] ``` """ diff --git a/tests/unit/common/test_deprecation.py b/tests/unit/common/test_deprecation.py deleted file mode 100644 index 850a99e2..00000000 --- a/tests/unit/common/test_deprecation.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -import pytest - -from rampart.common.deprecation import emit_deprecation_warning - - -class TestEmitDeprecationWarning: - def test_emits_deprecation_warning_for_string_items(self) -> None: - with pytest.warns( - DeprecationWarning, - match="old thing is deprecated and will be removed in 1.0.0", - ): - emit_deprecation_warning( - old_item="old thing", - new_item="new thing", - removed_in="1.0.0", - ) - - def test_message_names_the_replacement(self) -> None: - with pytest.warns(DeprecationWarning, match="Use new thing instead") as record: - emit_deprecation_warning( - old_item="old thing", - new_item="new thing", - removed_in="1.0.0", - ) - assert "removed in 1.0.0" in str(record[0].message) - - def test_uses_qualified_name_for_callables(self) -> None: - def some_old_func() -> None: ... - - def some_new_func() -> None: ... - - with pytest.warns(DeprecationWarning, match="some_old_func") as record: - emit_deprecation_warning( - old_item=some_old_func, - new_item=some_new_func, - removed_in="2.0.0", - ) - message = str(record[0].message) - assert some_old_func.__qualname__ in message - assert some_new_func.__qualname__ in message - assert "2.0.0" in message diff --git a/tests/unit/pytest_plugin/test_plugin.py b/tests/unit/pytest_plugin/test_plugin.py index d47ebba0..ca28614b 100644 --- a/tests/unit/pytest_plugin/test_plugin.py +++ b/tests/unit/pytest_plugin/test_plugin.py @@ -27,7 +27,6 @@ _emit_sinks, _enforce_incomplete_exit_status, _evaluate_gates, - _has_sink_hook_impl, _rampart_key, _received_result_counts_key, _resolve_hook_sinks, @@ -952,6 +951,7 @@ def test_sets_duration(self) -> None: config_stash[_rampart_key] = rs config_stash[_session_start_key] = time.monotonic() - 5.0 session_mock.config.stash = config_stash + session_mock.config.getoption.return_value = False session_mock.items = [] pytest_sessionfinish(session=cast("pytest.Session", session_mock), exitstatus=0) @@ -963,18 +963,6 @@ def test_sets_duration(self) -> None: class TestSinkHookResolution: """The pytest_rampart_sinks hook is resolved and validated.""" - def test_has_sink_hook_impl_true_when_impls_present(self) -> None: - config = MagicMock() - hook = config.pluginmanager.hook.pytest_rampart_sinks - hook.get_hookimpls.return_value = [MagicMock()] - assert _has_sink_hook_impl(config=config) is True - - def test_has_sink_hook_impl_false_when_no_impls(self) -> None: - config = MagicMock() - hook = config.pluginmanager.hook.pytest_rampart_sinks - hook.get_hookimpls.return_value = [] - assert _has_sink_hook_impl(config=config) is False - def test_resolve_hook_sinks_flattens_implementations(self) -> None: sink_a = MagicMock(spec=ReportSink) sink_b = MagicMock(spec=ReportSink) diff --git a/tests/unit/pytest_plugin/test_xdist.py b/tests/unit/pytest_plugin/test_xdist.py index a477ac0d..46c77875 100644 --- a/tests/unit/pytest_plugin/test_xdist.py +++ b/tests/unit/pytest_plugin/test_xdist.py @@ -49,7 +49,6 @@ attach_report_results, deserialize_report_data, deserialize_trial_specs, - discover_sinks_from_conftest, finalize_worker, get_dist_mode, get_worker_count, @@ -60,7 +59,7 @@ serialize_report_data, serialize_worker_data, ) -from rampart.reporting.sink import ReportSink, TestRunReport +from rampart.reporting.sink import TestRunReport def _make_result( @@ -1351,145 +1350,6 @@ def test_minimum_cap_contains_escaped_attribution(self, escaped: str) -> None: ) -class TestSinkDiscovery: - def test_finds_callable_rampart_sinks(self) -> None: - sink = MagicMock(spec=ReportSink) - plugin = MagicMock( - spec=["rampart_sinks", "__name__"], - rampart_sinks=lambda: [sink], - __name__="mod", - ) - config = MagicMock() - config.pluginmanager.get_plugins.return_value = [plugin] - result = discover_sinks_from_conftest(config=config) - assert sink in result - - def test_finds_list_rampart_sinks(self) -> None: - sink = MagicMock(spec=ReportSink) - plugin = MagicMock( - spec=["rampart_sinks", "__name__"], - rampart_sinks=[sink], - __name__="mod", - ) - config = MagicMock() - config.pluginmanager.get_plugins.return_value = [plugin] - result = discover_sinks_from_conftest(config=config) - assert sink in result - - def test_returns_empty_when_no_rampart_sinks(self) -> None: - plugin = MagicMock(spec=["__name__"], __name__="mod") - config = MagicMock() - config.pluginmanager.get_plugins.return_value = [plugin] - result = discover_sinks_from_conftest(config=config) - assert result == [] - - def test_warns_on_callable_with_required_args( - self, - caplog: pytest.LogCaptureFixture, - ) -> None: - def needs_arg(other: object) -> list[ReportSink]: - return [] - - plugin = MagicMock( - spec=["rampart_sinks", "__name__"], - rampart_sinks=needs_arg, - __name__="mod", - ) - config = MagicMock() - config.pluginmanager.get_plugins.return_value = [plugin] - with caplog.at_level(logging.WARNING): - result = discover_sinks_from_conftest(config=config) - assert result == [] - assert any("requires arguments" in r.getMessage() for r in caplog.records) - - def test_resolves_parameterless_fixture_form(self) -> None: - sink = MagicMock(spec=ReportSink) - - @pytest.fixture - def rampart_sinks() -> list[ReportSink]: - return [sink] - - plugin = MagicMock( - spec=["rampart_sinks", "__name__"], - rampart_sinks=rampart_sinks, - __name__="mod", - ) - config = MagicMock() - config.pluginmanager.get_plugins.return_value = [plugin] - result = discover_sinks_from_conftest(config=config) - assert sink in result - - def test_warns_and_skips_fixture_with_dependencies( - self, - caplog: pytest.LogCaptureFixture, - ) -> None: - @pytest.fixture - def rampart_sinks(tmp_path: object) -> list[ReportSink]: - return [] - - plugin = MagicMock( - spec=["rampart_sinks", "__name__"], - rampart_sinks=rampart_sinks, - __name__="mod", - ) - config = MagicMock() - config.pluginmanager.get_plugins.return_value = [plugin] - with caplog.at_level(logging.WARNING): - result = discover_sinks_from_conftest(config=config) - assert result == [] - assert any("requires arguments" in r.getMessage() for r in caplog.records) - assert any("pytest_rampart_sinks" in r.getMessage() for r in caplog.records) - - -class TestSinkDeprecationWarning: - """Deprecation-warning contract for controller-side ``rampart_sinks`` discovery. - - The ``@pytest.fixture`` form warns when resolved; the module-level list form - is not a fixture and must stay silent. These fast, in-process checks replace - the equivalent ``pytester`` subprocess test in ``test_xdist_aggregation.py``. - """ - - def test_fixture_form_emits_deprecation_warning(self) -> None: - sink = MagicMock(spec=ReportSink) - - @pytest.fixture - def rampart_sinks() -> list[ReportSink]: - return [sink] - - plugin = MagicMock( - spec=["rampart_sinks", "__name__"], - rampart_sinks=rampart_sinks, - __name__="mod", - ) - config = MagicMock() - config.pluginmanager.get_plugins.return_value = [plugin] - with pytest.warns( - DeprecationWarning, match="rampart_sinks fixture is deprecated" - ): - result = discover_sinks_from_conftest(config=config) - assert sink in result - - def test_list_form_does_not_emit_deprecation_warning( - self, - recwarn: pytest.WarningsRecorder, - ) -> None: - sink = MagicMock(spec=ReportSink) - plugin = MagicMock( - spec=["rampart_sinks", "__name__"], - rampart_sinks=[sink], - __name__="mod", - ) - config = MagicMock() - config.pluginmanager.get_plugins.return_value = [plugin] - result = discover_sinks_from_conftest(config=config) - assert sink in result - assert not any( - issubclass(w.category, DeprecationWarning) - and "rampart_sinks fixture is deprecated" in str(w.message) - for w in recwarn - ) - - class TestReportTestRunMetadata: def test_set_report_metadata_appears_in_report(self) -> None: session = RampartSession() diff --git a/tests/unit/pytest_plugin/test_xdist_aggregation.py b/tests/unit/pytest_plugin/test_xdist_aggregation.py index 0a88575f..2c6f942c 100644 --- a/tests/unit/pytest_plugin/test_xdist_aggregation.py +++ b/tests/unit/pytest_plugin/test_xdist_aggregation.py @@ -31,16 +31,13 @@ _CONFTEST = """\ from pathlib import Path -import pytest - from rampart.reporting import JsonFileReportSink _OUT_DIR = Path("rampart_reports").absolute() -@pytest.fixture(scope="session") -def rampart_sinks(): +def pytest_rampart_sinks(config): _OUT_DIR.mkdir(parents=True, exist_ok=True) Path("rampart_report_dir.txt").write_text(str(_OUT_DIR)) return [JsonFileReportSink(output_dir=_OUT_DIR)] @@ -712,32 +709,3 @@ def _trial_ids(lines: list[str]) -> list[str]: # deterministic clone IDs so that workers can match them. if serial_ids and parallel_ids: assert serial_ids == parallel_ids - - -class TestSinkFixtureDeprecation: - """End-to-end deprecation-warning contract for the ``rampart_sinks`` fixture. - - The fixture warns wherever it is resolved: single-process and on the xdist - controller. The list form's silence is covered by the fast unit tests in - ``test_xdist.py::TestSinkDeprecationWarning``. - """ - - _DEPRECATION_LINE = "*rampart_sinks fixture is deprecated*" - - def test_single_process_fixture_warns( - self, - configured_pytester: Pytester, - ) -> None: - _setup_simple_tests(configured_pytester) - result = configured_pytester.runpytest("-p", "no:cacheprovider") - result.assert_outcomes(passed=4) - result.stdout.fnmatch_lines([self._DEPRECATION_LINE]) - - def test_controller_fixture_warns_under_xdist( - self, - configured_pytester: Pytester, - ) -> None: - _setup_simple_tests(configured_pytester) - result = configured_pytester.runpytest("-p", "no:cacheprovider", "-n", "2") - result.assert_outcomes(passed=4) - result.stdout.fnmatch_lines([self._DEPRECATION_LINE])