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
51 changes: 51 additions & 0 deletions .agents/decisions/0004-httpx2-respx-test-aliasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR 0004: httpx2 Integration, Respx Mocking, and Test-Guarded Process Aliasing

---
status: accepted
date: 2026-07-25
decider: Kilo59 / Agent
---

## Context & Motivation for Switching to `httpx2`

`ruff-sync` relies on an asynchronous HTTP client to pull upstream Ruff linter configurations from GitHub and GitLab repositories.

We migrated from `httpx` to **`httpx2`** based on the official rationale provided by **Pydantic Services**:
- **Active Stewardship & Security Maintenance**: Due to a significant period of reduced maintenance activity and inactivity on the original `httpx` package, Pydantic Services created `httpx2` as an actively maintained successor to guarantee timely security updates, bug fixes, and continuous maintenance for a critical core library in the Python ecosystem.
- **Ecosystem Continuity**: The "2" in `httpx2` functions as a versioning marker for this new era of Pydantic Services maintenance rather than a breaking paradigm rewrite, preserving full compatibility while delivering active upstream support.
- **Strict Typing**: `httpx2` provides first-class typing out of the box, integrating cleanly with our `mypy` strict mode requirements.

Previously, HTTP test mocking relied on the third-party `pytest-httpx2` plugin. We needed to clean up our HTTP dependencies, eliminate `pytest-httpx2`, and migrate to standard `respx` (`>=0.23.1`) while ensuring zero side-effects on production execution.

## Decision

1. **Direct Application Imports**: Application runtime code (`src/ruff_sync/core.py`) imports `httpx2` directly (`import httpx2 as httpx`).
2. **Elimination of `pytest-httpx2`**: We completely removed `pytest-httpx2` from dependencies due to confusion and transport hook coupling.
3. **Test-Guarded Process Aliasing (`src/sitecustomize.py`)**: We place `httpx2.alias_httpx()` inside `src/sitecustomize.py` guarded by a runtime check (`any("pytest" in arg for arg in sys.argv) or "PYTEST_CURRENT_TEST" in os.environ`).

## Rationale & Technical Tradeoffs

### 1. Removal of `pytest-httpx2`
Beyond introducing transport hook conflicts and entrypoint ordering bugs, `pytest-httpx2` caused developer confusion:
- `pytest-httpx2` is a wrapper around `respx`, but its name frequently leads developers to the documentation for `pytest-httpx` / `httpx_pytest`.
- This created confusion around fixture names, routing syntax, and unexpected mocking behavior.
Eliminating `pytest-httpx2` removes this confusing wrapper layer and standardizes our test suite directly on `respx`.

### 2. No Production HTTP Plugin Dependencies
`ruff-sync` runtime code does **not** depend on third-party `httpcore` or `httpx` plugins in production.
If our production runtime relied on external plugins that hard-coded `import httpx` or `import httpcore`, we would require process-wide aliasing at production application startup. Because `ruff-sync` manages its HTTP transport directly via `httpx2`, aliasing is only necessary during test suite execution when `respx` is loaded by Pytest.

### 3. Packaging Exclusion
`src/sitecustomize.py` lives outside `src/ruff_sync/` and is strictly excluded from wheel packages by Hatchling (`packages = ["src/ruff_sync"]`). Published `.whl` and `sdist` distributions never include or ship `sitecustomize.py` to end users.

### 4. Why Disabling `respx` Autoloading (`-p no:respx`) Is a Gnarly Upstream Problem
We evaluated disabling `respx` entrypoint autoloading via `addopts = ["-p", "no:respx"]` and manually declaring `pytest_plugins = ["respx"]` in `conftest.py`. However, this reveals a gnarly entrypoint race condition:
- `respx` registers a `pytest11` entrypoint that imports `httpx`/`httpcore` at plugin discovery time.
- Disabling `respx` autoloading and re-enabling it via `pytest_plugins` causes `respx` to initialize its `MockRouter` hooks *after* Python module resolution has settled. In end-to-end CLI tests where `httpx2.AsyncClient` is instantiated across sub-threads or CLI entrypoints, `httpx2` creates fresh `httpcore2` transport instances that bypass `respx`'s delayed transport hooks, causing `RESPX: some routes were not called!` errors.
- **Upstream Resolution Needed**: This conflict needs to be resolved **upstream** — either in `respx` (by deferring transport binding until active router context rather than entrypoint import time) or in `httpx2` (by providing native `httpcore2` transport interceptors for `respx` without requiring `sys.modules` aliasing). Until fixed upstream, `sitecustomize.py` is the only mechanism that forces `alias_httpx()` to run at Python interpreter launch before Pytest's entrypoint scanner initializes `respx`.

## References

- [ADR Index](./README.md)
- [httpx2 Migration Guide](https://httpx2.pydantic.dev/migration/)
- [Issue #202](https://github.com/Kilo59/ruff-sync/issues/202) / [PR #203](https://github.com/Kilo59/ruff-sync/pull/203)
1 change: 1 addition & 0 deletions .agents/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This is an internal record of architectural decisions for `ruff-sync`. These doc
| [0001](./0001-type-refactoring-strategy.md) | 2026-04-05 | Accepted | Type Refactoring Strategy |
| [0002](./0002-tui-node-ast.md) | 2026-04-05 | Accepted | TUI Node AST Architecture |
| [0003](./0003-argument-resolution-layers.md) | 2026-04-10 | Accepted | Two-Layer Argument Resolution (Transport vs Execution) |
| [0004](./0004-httpx2-respx-test-aliasing.md) | 2026-07-25 | Accepted | httpx2 Integration, Respx Mocking, and Test-Guarded Process Aliasing |

---
*For instructions on how to create or manage ADRs, see the [ADR Skill](../skills/adr/SKILL.md).*
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ uv run coverage run -m pytest -vv
- Whitespace/formatting preservation tests go in `tests/test_whitespace.py`.
- End-to-end lifecycle tests use fixture triples in `tests/lifecycle_tomls/` (`*_initial.toml`, `*_upstream.toml`, `*_final.toml`). Use the `invoke new-case` task to scaffold them.
- Tests should include **both** structural/whitespace assertions and **semantic** assertions (verifying the merged config values are correct).
- **HTTP Mocking & `sitecustomize.py`**: `ruff-sync` uses `httpx2` for HTTP fetching and `respx` for test mocking. `src/sitecustomize.py` contains a test execution guard (`if any("pytest" in arg for arg in sys.argv) or "PYTEST_CURRENT_TEST" in os.environ:`) that calls `httpx2.alias_httpx()` at interpreter launch during pytest runs. `src/sitecustomize.py` lives outside `src/ruff_sync/` and is strictly excluded from wheel packages by Hatchling (`packages = ["src/ruff_sync"]`), so end users never receive it.

## Invoke Tasks

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,24 @@ flowchart TD
style SemanticNode fill:#4b5563,color:#fff,stroke:#374151
```

## Development

For local development and testing:

```console
# Run test suite
uv run pytest -vv

# Run linter and formatter
uv run ruff check . --fix
uv run ruff format .

# Run type checker
uv run mypy .
```

> **Note on Test Environment**: Test mocking uses `respx` with `httpx2`. To ensure `respx` intercepts `httpx2` network calls during `pytest` runs before plugin entrypoints load, a guarded `src/sitecustomize.py` script is used in development. This script is strictly excluded from production wheel builds (`packages = ["src/ruff_sync"]`) and is never shipped to end users.

## Dogfooding

To see `ruff-sync` in action, this project automatically "dogfoods" its own configuration. Every pull request runs a `ruff-sync check` against the repository's own `pyproject.toml` using the `--output-format github` flag, providing real-time feedback and inline annotations whenever configuration drift is detected.
Expand Down
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ classifiers = [
"Topic :: Software Development :: Quality Assurance",
]
dependencies = [
"httpx2>=2.3.0",
"httpx2>=2.9.1",
"tomlkit>=0.12.3,<2.0.0",
"typing-extensions>=4.5.0",
]
Expand Down Expand Up @@ -57,7 +57,7 @@ dev = [
"pytest-codspeed>=4.3.0",
"pytest-icdiff>=0.9",
"pytest-textual-snapshot>=1.0.0",
"pytest-httpx2",
"respx>=0.23.1",
"ruamel-yaml>=0.18.6",
"ruff>=0.15.0",
"textual>=8.2.2",
Expand Down Expand Up @@ -259,9 +259,8 @@ required-imports = ["from __future__ import annotations"]
include = ["src/ruff_sync/*.py"]

[tool.pytest]
addopts = ["-p pytest_httpx2"]
pythonpath = ["src", "."]
log_level = "INFO"
markers = [
"benchmark: marks performance benchmarks (run separately for CodSpeed)",
"httpx2: configure the httpx2_mock fixture",
]
20 changes: 20 additions & 0 deletions src/sitecustomize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Process startup script to alias httpx to httpx2 during pytest test runs.

PACKAGING INTENT:
This file must NEVER be included in built wheels (.whl) or release distributions.
It lives outside `src/ruff_sync/` and is excluded from packaging by Hatchling
(`packages = ["src/ruff_sync"]`). If the build backend or target configuration is
ever changed in `pyproject.toml`, ensure this file remains strictly excluded.
"""

from __future__ import annotations

import os
import sys

# Only run alias_httpx() if pytest is running
if any("pytest" in arg for arg in sys.argv) or "PYTEST_CURRENT_TEST" in os.environ:
import httpx2 as httpx

if "httpx" not in sys.modules:
httpx.alias_httpx()
50 changes: 5 additions & 45 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import contextlib
import logging
import os
import sys
Expand All @@ -8,20 +9,19 @@
if TYPE_CHECKING:
from collections.abc import Generator

import contextlib

import pytest
import respx
import truststore
from respx.router import DEFAULT as RESPX_DEFAULT
from respx.router import MockRouter
from typing_extensions import override

import ruff_sync

with contextlib.suppress(Exception):
truststore.SSLContext()


import ruff_sync
pytest_plugins = ["respx"]


LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -150,43 +150,3 @@ def _run(
return exit_code, captured.out, captured.err

return _run


class HTTPX2MockRouter(MockRouter):
@override
def __call__(
self,
func=None,
*,
assert_all_called=None,
assert_all_mocked=None,
base_url=None,
using=RESPX_DEFAULT,
):
"""Override __call__ to set using='httpcore2' by default for nested mocks."""
if using is RESPX_DEFAULT:
using = "httpcore2"
return super().__call__(
func=func,
assert_all_called=assert_all_called,
assert_all_mocked=assert_all_mocked,
base_url=base_url,
using=using,
)


@pytest.fixture
def httpx2_mock(request: pytest.FixtureRequest) -> Generator[respx.Router, None, None]:
options = {}
if (marker := request.node.get_closest_marker("httpx2")) is not None:
options.update(marker.kwargs)
options.setdefault("using", "httpcore2")
router = HTTPX2MockRouter(**options)
with router:
yield router


@pytest.fixture
def respx_mock(httpx2_mock):
"""Fixture that maps respx_mock to httpx2_mock for httpx2 compatibility."""
return httpx2_mock
24 changes: 12 additions & 12 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,13 @@ def test_merge_ruff_toml(source: str, toml_s: str, sep_str: str):

@pytest.fixture
def mock_http(toml_s: str, respx_mock: respx.MockRouter) -> Generator[respx.MockRouter, None, None]:
with respx_mock(base_url="https://example.com/") as mock:
mock.get("/pyproject.toml").respond(
with respx_mock(base_url="https://example.com/") as http_mock:
http_mock.get("/pyproject.toml").respond(
200,
content_type="text/plain",
content=toml_s,
)
yield mock
yield http_mock


@pytest.fixture
Expand Down Expand Up @@ -610,8 +610,8 @@ async def test_sync_default_exclude(fs: FakeFilesystem, respx_mock: respx.MockRo
ff = fs.create_file("pyproject.toml", contents=source_toml)
ff_path = pathlib.Path(ff.path) # type: ignore[arg-type]

with respx_mock(base_url="https://example.com/") as mock:
mock.get("/pyproject.toml").respond(
with respx_mock(base_url="https://example.com/") as http_mock:
http_mock.get("/pyproject.toml").respond(
200,
content_type="text/plain",
content=upstream_toml,
Expand Down Expand Up @@ -669,7 +669,7 @@ def test_ruff_config_file_name_equality() -> None:


@pytest.mark.asyncio
async def test_merge_multiple_upstreams_preserves_order(respx_mock: respx.Router):
async def test_merge_multiple_upstreams_preserves_order(respx_mock: respx.MockRouter):
"""Verify that multiple upstreams are merged in the given order."""
# Setup mock data
target_doc = document()
Expand Down Expand Up @@ -701,7 +701,7 @@ async def test_merge_multiple_upstreams_preserves_order(respx_mock: respx.Router


@pytest.mark.asyncio
async def test_fetch_upstreams_concurrently_verified(respx_mock: respx.Router):
async def test_fetch_upstreams_concurrently_verified(respx_mock: respx.MockRouter):
"""Verify that fetch_upstreams_concurrently actually runs tasks in parallel."""
events = []

Expand Down Expand Up @@ -754,7 +754,7 @@ async def side_effect_two(request: original_httpx.Request) -> original_httpx.Res


@pytest.mark.asyncio
async def test_merge_multiple_upstreams_handles_errors(respx_mock: respx.Router):
async def test_merge_multiple_upstreams_handles_errors(respx_mock: respx.MockRouter):
target_doc = document()

args = ruff_sync.Arguments(
Expand Down Expand Up @@ -783,7 +783,7 @@ async def test_merge_multiple_upstreams_handles_errors(respx_mock: respx.Router)

def test_cli_surfaces_upstream_error_with_exit_code_and_logs(
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.Router,
respx_mock: respx.MockRouter,
capsys: pytest.CaptureFixture[str],
configure_logging: logging.Logger,
) -> None:
Expand Down Expand Up @@ -828,7 +828,7 @@ def test_cli_surfaces_upstream_error_with_exit_code_and_logs(

def test_cli_output_format_github(
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.Router,
respx_mock: respx.MockRouter,
capsys: pytest.CaptureFixture[str],
configure_logging: logging.Logger,
) -> None:
Expand Down Expand Up @@ -870,7 +870,7 @@ def test_cli_output_format_github(

def test_cli_output_format_json(
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.Router,
respx_mock: respx.MockRouter,
capsys: pytest.CaptureFixture[str],
configure_logging: logging.Logger,
) -> None:
Expand Down Expand Up @@ -930,7 +930,7 @@ def test_cli_output_format_json(

def test_cli_output_format_json_success(
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.Router,
respx_mock: respx.MockRouter,
capsys: pytest.CaptureFixture[str],
configure_logging: logging.Logger,
fs: FakeFilesystem,
Expand Down
Loading
Loading