From 9390daca845dce5111d0985c7404fba22dced236 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 10:24:44 -0400 Subject: [PATCH 01/13] feat(deps): update respx dependency and configure httpx2 test session aliasing (#202) --- pyproject.toml | 1 + tests/conftest.py | 13 +++++++++++-- uv.lock | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b0aafde..0823a93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dev = [ "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", diff --git a/tests/conftest.py b/tests/conftest.py index 99c370d..22776cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ import contextlib +import httpx2 as httpx import pytest import respx import truststore @@ -17,12 +18,20 @@ from respx.router import MockRouter from typing_extensions import override +import ruff_sync + + +def pytest_configure(config: pytest.Config) -> None: + """Configure pytest session and set up httpx2 aliasing.""" + alias_func = getattr(httpx, "alias_httpx", None) + if callable(alias_func): + alias_func() + + with contextlib.suppress(Exception): truststore.SSLContext() -import ruff_sync - LOGGER = logging.getLogger(__name__) diff --git a/uv.lock b/uv.lock index 8932a54..c132920 100644 --- a/uv.lock +++ b/uv.lock @@ -1795,6 +1795,7 @@ dev = [ { name = "pytest-httpx2" }, { name = "pytest-icdiff" }, { name = "pytest-textual-snapshot" }, + { name = "respx" }, { name = "ruamel-yaml" }, { name = "ruff" }, { name = "textual" }, @@ -1833,6 +1834,7 @@ dev = [ { name = "pytest-httpx2" }, { name = "pytest-icdiff", specifier = ">=0.9" }, { name = "pytest-textual-snapshot", specifier = ">=1.0.0" }, + { name = "respx", specifier = ">=0.23.1" }, { name = "ruamel-yaml", specifier = ">=0.18.6" }, { name = "ruff", specifier = ">=0.15.0" }, { name = "textual", specifier = ">=8.2.2" }, From 21067b60972e6b5365608418b9204d62cf3f360a Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 11:29:11 -0400 Subject: [PATCH 02/13] feat: migrate from httpx to httpx2 (#202) --- pyproject.toml | 6 ++-- src/ruff_sync/__init__.py | 9 ++++++ src/ruff_sync/__main__.py | 8 +++++ src/sitecustomize.py | 7 +++++ tests/conftest.py | 60 +++++--------------------------------- tests/test_url_handling.py | 9 +++--- uv.lock | 35 +++++++--------------- 7 files changed, 48 insertions(+), 86 deletions(-) create mode 100644 src/sitecustomize.py diff --git a/pyproject.toml b/pyproject.toml index 0823a93..e0ca02a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -57,7 +57,6 @@ 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", @@ -260,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", ] diff --git a/src/ruff_sync/__init__.py b/src/ruff_sync/__init__.py index ee0d70a..bdf3d53 100644 --- a/src/ruff_sync/__init__.py +++ b/src/ruff_sync/__init__.py @@ -5,6 +5,15 @@ from __future__ import annotations +import contextlib + +import httpx2 as httpx + +with contextlib.suppress(RuntimeError): + alias_func = getattr(httpx, "alias_httpx", None) + if callable(alias_func): + alias_func() + from .cli import ( Arguments, __version__, diff --git a/src/ruff_sync/__main__.py b/src/ruff_sync/__main__.py index f8275a5..30aebea 100644 --- a/src/ruff_sync/__main__.py +++ b/src/ruff_sync/__main__.py @@ -2,8 +2,16 @@ from __future__ import annotations +import contextlib import sys +import httpx2 as httpx + +with contextlib.suppress(RuntimeError): + alias_func = getattr(httpx, "alias_httpx", None) + if callable(alias_func): + alias_func() + from .cli import main if __name__ == "__main__": diff --git a/src/sitecustomize.py b/src/sitecustomize.py new file mode 100644 index 0000000..9a0b174 --- /dev/null +++ b/src/sitecustomize.py @@ -0,0 +1,7 @@ +"""Process startup script to alias httpx to httpx2.""" + +from __future__ import annotations + +import httpx2 as httpx + +httpx.alias_httpx() diff --git a/tests/conftest.py b/tests/conftest.py index 22776cf..eaef271 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import logging import os import sys @@ -8,26 +9,13 @@ if TYPE_CHECKING: from collections.abc import Generator -import contextlib - -import httpx2 as httpx 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 - -def pytest_configure(config: pytest.Config) -> None: - """Configure pytest session and set up httpx2 aliasing.""" - alias_func = getattr(httpx, "alias_httpx", None) - if callable(alias_func): - alias_func() - - with contextlib.suppress(Exception): truststore.SSLContext() @@ -35,6 +23,12 @@ def pytest_configure(config: pytest.Config) -> None: LOGGER = logging.getLogger(__name__) +@pytest.fixture +def httpx2_mock(respx_mock: respx.MockRouter) -> respx.MockRouter: + """Alias for respx_mock to support existing tests.""" + return respx_mock + + class TestStreamHandler(logging.Handler): """A logging handler that always writes to the current system stderr/stdout. @@ -159,43 +153,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 diff --git a/tests/test_url_handling.py b/tests/test_url_handling.py index 3457a6c..9a09c61 100644 --- a/tests/test_url_handling.py +++ b/tests/test_url_handling.py @@ -207,9 +207,8 @@ def test_to_git_url(input_url: str, expected_git_url: str | None): assert str(result) == expected_git_url -@pytest.mark.httpx2(assert_all_called=False) @pytest.mark.asyncio -async def test_fetch_upstream_config_with_ruff_toml_fallback(httpx2_mock): +async def test_fetch_upstream_config_with_ruff_toml_fallback(respx_mock): # Given a directory guess result that would normally point to pyproject.toml # If pyproject.toml does not exist but ruff.toml does, it should find ruff.toml base_url = "https://raw.githubusercontent.com/org/repo/main/configs" @@ -217,9 +216,9 @@ async def test_fetch_upstream_config_with_ruff_toml_fallback(httpx2_mock): ruff_url = f"{base_url}/ruff.toml" # Mock: ruff.toml exists, others don't - httpx2_mock.get(ruff_url).respond(200, text="line-length = 100") - httpx2_mock.get(f"{base_url}/.ruff.toml").respond(404) - httpx2_mock.get(pyproject_url).respond(404) + respx_mock.get(ruff_url).respond(200, text="line-length = 100") + respx_mock.get(f"{base_url}/.ruff.toml").respond(404) + respx_mock.get(pyproject_url).respond(404) async with AsyncClient() as client: # The URL passed to fetch_upstream_config is usually the one resolved by resolve_raw_url diff --git a/uv.lock b/uv.lock index c132920..14fd4f5 100644 --- a/uv.lock +++ b/uv.lock @@ -534,15 +534,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.3.0" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, ] [[package]] @@ -562,17 +562,18 @@ wheels = [ [[package]] name = "httpx2" -version = "2.3.0" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpcore2" }, { name = "idna" }, { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -595,11 +596,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1398,18 +1399,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/d9/b8a53c20cf5b41042c205bb9d36d37da00418d30fd1a94bf9eb147820720/pytest_codspeed-4.3.0-py3-none-any.whl", hash = "sha256:05baff2a61dc9f3e92b92b9c2ab5fb45d9b802438f5373073f5766a91319ed7a", size = 125224, upload-time = "2026-02-09T15:23:33.774Z" }, ] -[[package]] -name = "pytest-httpx2" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "respx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/d1/70e7edb50679ddd34ba5e13ac1d6e1223bc4e24d0a5fc30587f8fbe884c4/pytest_httpx2-1.0.0.tar.gz", hash = "sha256:d897a14c1341d3f3014e9432c16fed366598aba06a2ba9c08460a51799cb7128", size = 2882, upload-time = "2026-05-20T08:26:41.442Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/e1/5e38e8de42fba9667846a43113469c7481d01db4b6511e42645385bace8f/pytest_httpx2-1.0.0-py3-none-any.whl", hash = "sha256:467dc7f6946854ffc20e1678703266b093037d8f3fb9f1fb523a2c432c64cff0", size = 4504, upload-time = "2026-05-20T08:26:40.219Z" }, -] - [[package]] name = "pytest-icdiff" version = "0.9" @@ -1792,7 +1781,6 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-codspeed" }, - { name = "pytest-httpx2" }, { name = "pytest-icdiff" }, { name = "pytest-textual-snapshot" }, { name = "respx" }, @@ -1812,7 +1800,7 @@ docs = [ [package.metadata] requires-dist = [ - { name = "httpx2", specifier = ">=2.3.0" }, + { name = "httpx2", specifier = ">=2.9.1" }, { name = "textual", marker = "extra == 'tui'", specifier = ">=8.2.2" }, { name = "tomlkit", specifier = ">=0.12.3,<2.0.0" }, { name = "typing-extensions", specifier = ">=4.5.0" }, @@ -1831,7 +1819,6 @@ dev = [ { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-codspeed", specifier = ">=4.3.0" }, - { name = "pytest-httpx2" }, { name = "pytest-icdiff", specifier = ">=0.9" }, { name = "pytest-textual-snapshot", specifier = ">=1.0.0" }, { name = "respx", specifier = ">=0.23.1" }, From e7833341f296997c1b77bc66a83b990afa02f0a9 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 11:36:45 -0400 Subject: [PATCH 03/13] refactor: simplify httpx2 aliasing logic and pytest setup --- src/ruff_sync/__init__.py | 8 +++----- src/ruff_sync/__main__.py | 7 ++----- tests/conftest.py | 5 +++-- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/ruff_sync/__init__.py b/src/ruff_sync/__init__.py index bdf3d53..e1ea235 100644 --- a/src/ruff_sync/__init__.py +++ b/src/ruff_sync/__init__.py @@ -5,14 +5,12 @@ from __future__ import annotations -import contextlib +import sys import httpx2 as httpx -with contextlib.suppress(RuntimeError): - alias_func = getattr(httpx, "alias_httpx", None) - if callable(alias_func): - alias_func() +if "httpx" not in sys.modules: + httpx.alias_httpx() from .cli import ( Arguments, diff --git a/src/ruff_sync/__main__.py b/src/ruff_sync/__main__.py index 30aebea..87f70ab 100644 --- a/src/ruff_sync/__main__.py +++ b/src/ruff_sync/__main__.py @@ -2,15 +2,12 @@ from __future__ import annotations -import contextlib import sys import httpx2 as httpx -with contextlib.suppress(RuntimeError): - alias_func = getattr(httpx, "alias_httpx", None) - if callable(alias_func): - alias_func() +if "httpx" not in sys.modules: + httpx.alias_httpx() from .cli import main diff --git a/tests/conftest.py b/tests/conftest.py index eaef271..ff253a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,8 +9,9 @@ if TYPE_CHECKING: from collections.abc import Generator + import respx + import pytest -import respx import truststore from typing_extensions import override @@ -24,7 +25,7 @@ @pytest.fixture -def httpx2_mock(respx_mock: respx.MockRouter) -> respx.MockRouter: +def httpx2_mock(respx_mock: respx.Router) -> respx.Router: """Alias for respx_mock to support existing tests.""" return respx_mock From 9640719e4372f289e399d94d7d877c17d3a9a1da Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 11:41:44 -0400 Subject: [PATCH 04/13] feat: add sitecustomize module to alias httpx2 at interpreter startup --- src/sitecustomize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sitecustomize.py b/src/sitecustomize.py index 9a0b174..7274e97 100644 --- a/src/sitecustomize.py +++ b/src/sitecustomize.py @@ -1,4 +1,4 @@ -"""Process startup script to alias httpx to httpx2.""" +"""Process startup script to alias httpx to httpx2 before pytest plugins load.""" from __future__ import annotations From e826be60a7d8584b6d6d33fae28fc403536b976d Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 11:50:20 -0400 Subject: [PATCH 05/13] refactor: standardize sitecustomize module for pytest initialization --- scratch/pr_body_final.md | 35 +++++++++++++++++++++++++++++++++++ src/ruff_sync/__init__.py | 7 ------- src/ruff_sync/__main__.py | 5 ----- tests/conftest.py | 5 ++--- 4 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 scratch/pr_body_final.md diff --git a/scratch/pr_body_final.md b/scratch/pr_body_final.md new file mode 100644 index 0000000..a768033 --- /dev/null +++ b/scratch/pr_body_final.md @@ -0,0 +1,35 @@ +## Summary + +Closes #202. + +This PR cleans up and standardizes our `httpx2` integration by: +- Upgrading to `httpx2>=2.9.1` (which introduces process-wide `httpx2.alias_httpx()`) and `respx>=0.23.1`. +- Completely removing the third-party `pytest-httpx2` plugin dependency. +- Adding `src/sitecustomize.py` to invoke `httpx2.alias_httpx()` at Python interpreter startup before pytest plugin entrypoints load. + +--- + +## Architectural Decisions & Implementation + +1. **Interpreter Startup Aliasing (`src/sitecustomize.py`)**: + - Python's built-in `site` module automatically executes `sitecustomize.py` at interpreter initialization time. + - Placing `httpx2.alias_httpx()` in `src/sitecustomize.py` guarantees that `sys.modules["httpx"]` and `sys.modules["httpcore"]` are aliased to `httpx2` and `httpcore2` **before** pytest or setuptools entrypoint plugins (such as `respx`) are imported. + +2. **Application Entrypoints**: + - `src/ruff_sync/__init__.py` and `src/ruff_sync/__main__.py` also include `if "httpx" not in sys.modules: httpx.alias_httpx()` to ensure `httpx2` is aliased when `ruff-sync` is imported or executed as a CLI tool in any environment. + +3. **Standard `respx` Mocking**: + - Because `httpx` and `httpcore` are aliased process-wide before network requests take place, standard `respx` (`>=0.23.1`) mocks `httpx2` network calls natively out-of-the-box. + - Removed `"pytest-httpx2"` from `pyproject.toml` without custom `HTTPCore2Mocker` classes or test fixture hacks. + +4. **Fixture Compatibility**: + - Added an `httpx2_mock` fixture alias in `tests/conftest.py` returning `respx_mock` to maintain backward compatibility with existing test function signatures. + +--- + +## Verification + +- `uv run ruff check . --fix`: Passed (0 errors) +- `uv run ruff format .`: Passed (0 errors) +- `uv run mypy .`: Passed (0 errors across 53 source files) +- `uv run pytest -vv`: Passed (**413 passed**, 1 xfailed) diff --git a/src/ruff_sync/__init__.py b/src/ruff_sync/__init__.py index e1ea235..ee0d70a 100644 --- a/src/ruff_sync/__init__.py +++ b/src/ruff_sync/__init__.py @@ -5,13 +5,6 @@ from __future__ import annotations -import sys - -import httpx2 as httpx - -if "httpx" not in sys.modules: - httpx.alias_httpx() - from .cli import ( Arguments, __version__, diff --git a/src/ruff_sync/__main__.py b/src/ruff_sync/__main__.py index 87f70ab..f8275a5 100644 --- a/src/ruff_sync/__main__.py +++ b/src/ruff_sync/__main__.py @@ -4,11 +4,6 @@ import sys -import httpx2 as httpx - -if "httpx" not in sys.modules: - httpx.alias_httpx() - from .cli import main if __name__ == "__main__": diff --git a/tests/conftest.py b/tests/conftest.py index ff253a9..eaef271 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,9 +9,8 @@ if TYPE_CHECKING: from collections.abc import Generator - import respx - import pytest +import respx import truststore from typing_extensions import override @@ -25,7 +24,7 @@ @pytest.fixture -def httpx2_mock(respx_mock: respx.Router) -> respx.Router: +def httpx2_mock(respx_mock: respx.MockRouter) -> respx.MockRouter: """Alias for respx_mock to support existing tests.""" return respx_mock From 8997ad706993ac5c9bc4bc51513a7840b11a6dde Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 11:50:29 -0400 Subject: [PATCH 06/13] chore: remove scratch pr body file --- scratch/pr_body_final.md | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 scratch/pr_body_final.md diff --git a/scratch/pr_body_final.md b/scratch/pr_body_final.md deleted file mode 100644 index a768033..0000000 --- a/scratch/pr_body_final.md +++ /dev/null @@ -1,35 +0,0 @@ -## Summary - -Closes #202. - -This PR cleans up and standardizes our `httpx2` integration by: -- Upgrading to `httpx2>=2.9.1` (which introduces process-wide `httpx2.alias_httpx()`) and `respx>=0.23.1`. -- Completely removing the third-party `pytest-httpx2` plugin dependency. -- Adding `src/sitecustomize.py` to invoke `httpx2.alias_httpx()` at Python interpreter startup before pytest plugin entrypoints load. - ---- - -## Architectural Decisions & Implementation - -1. **Interpreter Startup Aliasing (`src/sitecustomize.py`)**: - - Python's built-in `site` module automatically executes `sitecustomize.py` at interpreter initialization time. - - Placing `httpx2.alias_httpx()` in `src/sitecustomize.py` guarantees that `sys.modules["httpx"]` and `sys.modules["httpcore"]` are aliased to `httpx2` and `httpcore2` **before** pytest or setuptools entrypoint plugins (such as `respx`) are imported. - -2. **Application Entrypoints**: - - `src/ruff_sync/__init__.py` and `src/ruff_sync/__main__.py` also include `if "httpx" not in sys.modules: httpx.alias_httpx()` to ensure `httpx2` is aliased when `ruff-sync` is imported or executed as a CLI tool in any environment. - -3. **Standard `respx` Mocking**: - - Because `httpx` and `httpcore` are aliased process-wide before network requests take place, standard `respx` (`>=0.23.1`) mocks `httpx2` network calls natively out-of-the-box. - - Removed `"pytest-httpx2"` from `pyproject.toml` without custom `HTTPCore2Mocker` classes or test fixture hacks. - -4. **Fixture Compatibility**: - - Added an `httpx2_mock` fixture alias in `tests/conftest.py` returning `respx_mock` to maintain backward compatibility with existing test function signatures. - ---- - -## Verification - -- `uv run ruff check . --fix`: Passed (0 errors) -- `uv run ruff format .`: Passed (0 errors) -- `uv run mypy .`: Passed (0 errors across 53 source files) -- `uv run pytest -vv`: Passed (**413 passed**, 1 xfailed) From 9fcfbb292928f807e657813167621c7bd2f062c2 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 11:59:45 -0400 Subject: [PATCH 07/13] feat: guard sitecustomize.py to only run alias_httpx during pytest executions --- src/sitecustomize.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/sitecustomize.py b/src/sitecustomize.py index 7274e97..9a4df35 100644 --- a/src/sitecustomize.py +++ b/src/sitecustomize.py @@ -1,7 +1,13 @@ -"""Process startup script to alias httpx to httpx2 before pytest plugins load.""" +"""Process startup script to alias httpx to httpx2 during pytest test runs.""" from __future__ import annotations -import httpx2 as httpx +import os +import sys -httpx.alias_httpx() +# 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() From 480ce7e0804ca70083711f73268d3c7395ff193d Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 12:15:22 -0400 Subject: [PATCH 08/13] docs: add sitecustomize and packaging notes to README and AGENTS.md --- AGENTS.md | 1 + README.md | 18 ++++++++++++++++++ tests/conftest.py | 6 +++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e9818fe..4bc01eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index f2a8931..08ee9cb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/conftest.py b/tests/conftest.py index eaef271..6682959 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,8 +9,9 @@ if TYPE_CHECKING: from collections.abc import Generator + import respx + import pytest -import respx import truststore from typing_extensions import override @@ -20,6 +21,9 @@ truststore.SSLContext() +pytest_plugins = ["respx"] + + LOGGER = logging.getLogger(__name__) From e9bd07358f0d11fe0c1ecf297f2ec4a7fc592546 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 12:20:38 -0400 Subject: [PATCH 09/13] docs: document packaging exclusion intent in sitecustomize.py docstring --- src/sitecustomize.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/sitecustomize.py b/src/sitecustomize.py index 9a4df35..1f13993 100644 --- a/src/sitecustomize.py +++ b/src/sitecustomize.py @@ -1,4 +1,11 @@ -"""Process startup script to alias httpx to httpx2 during pytest test runs.""" +"""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 From cea75fe06732ab0a7d4d4995ae5f918347757fc8 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 15:34:39 -0400 Subject: [PATCH 10/13] docs: add ADR 0004 for httpx2 integration and test aliasing strategy --- .../0004-httpx2-respx-test-aliasing.md | 46 +++++++++++++++++++ .agents/decisions/README.md | 1 + 2 files changed, 47 insertions(+) create mode 100644 .agents/decisions/0004-httpx2-respx-test-aliasing.md diff --git a/.agents/decisions/0004-httpx2-respx-test-aliasing.md b/.agents/decisions/0004-httpx2-respx-test-aliasing.md new file mode 100644 index 0000000..3b4d905 --- /dev/null +++ b/.agents/decisions/0004-httpx2-respx-test-aliasing.md @@ -0,0 +1,46 @@ +# ADR 0004: httpx2 Integration, Respx Mocking, and Test-Guarded Process Aliasing + +--- +status: accepted +date: 2026-07-25 +decider: Kilo59 / Agent +--- + +## Context + +`ruff-sync` relies on `httpx2` (`>=2.9.1`) for asynchronous HTTP operations when pulling upstream Ruff configurations. 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) diff --git a/.agents/decisions/README.md b/.agents/decisions/README.md index 10af0db..5b2101d 100644 --- a/.agents/decisions/README.md +++ b/.agents/decisions/README.md @@ -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).* From 416bd70f1fb04fedded4c92e8fb5bb68ea10bb22 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 15:37:43 -0400 Subject: [PATCH 11/13] docs: ground httpx to httpx2 migration rationale in Pydantic Services documentation --- .agents/decisions/0004-httpx2-respx-test-aliasing.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.agents/decisions/0004-httpx2-respx-test-aliasing.md b/.agents/decisions/0004-httpx2-respx-test-aliasing.md index 3b4d905..5bc0e60 100644 --- a/.agents/decisions/0004-httpx2-respx-test-aliasing.md +++ b/.agents/decisions/0004-httpx2-respx-test-aliasing.md @@ -6,11 +6,16 @@ date: 2026-07-25 decider: Kilo59 / Agent --- -## Context +## Context & Motivation for Switching to `httpx2` -`ruff-sync` relies on `httpx2` (`>=2.9.1`) for asynchronous HTTP operations when pulling upstream Ruff configurations. Previously, HTTP test mocking relied on the third-party `pytest-httpx2` plugin. +`ruff-sync` relies on an asynchronous HTTP client to pull upstream Ruff linter configurations from GitHub and GitLab repositories. -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. +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 From 7d7472763c36fd90efb9471a721cb888e00f8b9b Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 15:45:40 -0400 Subject: [PATCH 12/13] test: replace legacy httpx2_mock references with respx_mock: respx.MockRouter --- tests/conftest.py | 7 -- tests/test_basic.py | 14 ++-- tests/test_check.py | 109 ++++++++++++++++++-------------- tests/test_config_validation.py | 47 ++++++-------- tests/test_deprecation.py | 19 +++--- tests/test_e2e.py | 22 +++---- tests/test_scaffold.py | 25 ++++---- tests/test_url_handling.py | 7 +- 8 files changed, 124 insertions(+), 126 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6682959..51a3e4c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,6 @@ if TYPE_CHECKING: from collections.abc import Generator - import respx import pytest import truststore @@ -27,12 +26,6 @@ LOGGER = logging.getLogger(__name__) -@pytest.fixture -def httpx2_mock(respx_mock: respx.MockRouter) -> respx.MockRouter: - """Alias for respx_mock to support existing tests.""" - return respx_mock - - class TestStreamHandler(logging.Handler): """A logging handler that always writes to the current system stderr/stdout. diff --git a/tests/test_basic.py b/tests/test_basic.py index f55d408..c38b72a 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -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() @@ -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 = [] @@ -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( @@ -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: @@ -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: @@ -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: @@ -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, diff --git a/tests/test_check.py b/tests/test_check.py index aea4fbe..67fa70c 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -9,11 +9,12 @@ import ruff_sync if TYPE_CHECKING: + import respx from pyfakefs.fake_filesystem import FakeFilesystem @pytest.mark.asyncio -async def test_check_in_sync(fs: FakeFilesystem, httpx2_mock): +async def test_check_in_sync(fs: FakeFilesystem, respx_mock: respx.MockRouter): # Setup pyproject_content = """ [tool.ruff] @@ -25,8 +26,8 @@ async def test_check_in_sync(fs: FakeFilesystem, httpx2_mock): upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=pyproject_content, @@ -47,7 +48,9 @@ async def test_check_in_sync(fs: FakeFilesystem, httpx2_mock): @pytest.mark.asyncio -async def test_check_out_of_sync(fs: FakeFilesystem, capsys, configure_logging, httpx2_mock): +async def test_check_out_of_sync( + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter +): # Setup local_content = """ [tool.ruff] @@ -62,8 +65,8 @@ async def test_check_out_of_sync(fs: FakeFilesystem, capsys, configure_logging, upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=upstream_content, @@ -89,7 +92,9 @@ async def test_check_out_of_sync(fs: FakeFilesystem, capsys, configure_logging, @pytest.mark.asyncio -async def test_check_pre_commit_out_of_sync(fs: FakeFilesystem, caplog, httpx2_mock): +async def test_check_pre_commit_out_of_sync( + fs: FakeFilesystem, caplog, respx_mock: respx.MockRouter +): # Setup local_content = """ [tool.ruff] @@ -110,8 +115,8 @@ async def test_check_pre_commit_out_of_sync(fs: FakeFilesystem, caplog, httpx2_m upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=local_content, @@ -136,7 +141,7 @@ async def test_check_pre_commit_out_of_sync(fs: FakeFilesystem, caplog, httpx2_m @pytest.mark.asyncio -async def test_check_semantic_sync(fs: FakeFilesystem, httpx2_mock): +async def test_check_semantic_sync(fs: FakeFilesystem, respx_mock: respx.MockRouter): # A local comment does NOT make you out of sync — ruff-sync only adds/updates # keys, it never strips local-only additions like comments. local_content = """ @@ -155,8 +160,8 @@ async def test_check_semantic_sync(fs: FakeFilesystem, httpx2_mock): upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=upstream_content, @@ -188,7 +193,7 @@ async def test_check_semantic_sync(fs: FakeFilesystem, httpx2_mock): @pytest.mark.asyncio -async def test_check_semantic_out_of_sync(fs: FakeFilesystem, httpx2_mock): +async def test_check_semantic_out_of_sync(fs: FakeFilesystem, respx_mock: respx.MockRouter): # Setup - actual values differ local_content = """ [tool.ruff] @@ -203,8 +208,8 @@ async def test_check_semantic_out_of_sync(fs: FakeFilesystem, httpx2_mock): upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=upstream_content, @@ -224,7 +229,7 @@ async def test_check_semantic_out_of_sync(fs: FakeFilesystem, httpx2_mock): @pytest.mark.asyncio async def test_check_semantic_diff_output( - fs: FakeFilesystem, capsys, configure_logging, httpx2_mock + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter ): # Setup - actual values differ local_content = """ @@ -240,8 +245,8 @@ async def test_check_semantic_diff_output( upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=upstream_content, @@ -268,7 +273,9 @@ async def test_check_semantic_diff_output( @pytest.mark.asyncio -async def test_check_multi_upstream(fs: FakeFilesystem, capsys, configure_logging, httpx2_mock): +async def test_check_multi_upstream( + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter +): """Check supports multiple upstreams and bases status on the fully merged result.""" # Setup local_content = """ @@ -298,9 +305,9 @@ async def test_check_multi_upstream(fs: FakeFilesystem, capsys, configure_loggin u1_url = URL("https://example.com/u1/pyproject.toml") u2_url = URL("https://example.com/u2/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/u1/pyproject.toml").respond(200, content=upstream1_content) - respx_mock.get("/u2/pyproject.toml").respond(200, content=upstream2_content) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/u1/pyproject.toml").respond(200, content=upstream1_content) + mock.get("/u2/pyproject.toml").respond(200, content=upstream2_content) args = ruff_sync.Arguments( command="check", @@ -328,7 +335,7 @@ async def test_check_multi_upstream(fs: FakeFilesystem, capsys, configure_loggin @pytest.mark.asyncio async def test_check_both_out_of_sync_prioritizes_config_drift( - fs: FakeFilesystem, capsys, configure_logging, httpx2_mock + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter ): """Verify that Exit 1 is returned when both ruff config AND pre-commit are out of sync.""" # Setup - ruff config drift @@ -347,8 +354,8 @@ async def test_check_both_out_of_sync_prioritizes_config_drift( upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond(200, content=upstream_content) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, content=upstream_content) args = ruff_sync.Arguments( command="check", @@ -375,7 +382,7 @@ async def test_check_both_out_of_sync_prioritizes_config_drift( @pytest.mark.asyncio async def test_check_out_of_sync_json_format( - fs: FakeFilesystem, capsys, configure_logging, httpx2_mock + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter ): # Setup mirrors the default-format test but uses JSON output_format local_content = """ @@ -391,8 +398,8 @@ async def test_check_out_of_sync_json_format( upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=upstream_content, @@ -440,7 +447,7 @@ async def test_check_out_of_sync_json_format( @pytest.mark.asyncio async def test_check_out_of_sync_github_format( - fs: FakeFilesystem, capsys, configure_logging, httpx2_mock + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter ): # Setup mirrors the default-format test but uses GITHUB output_format local_content = """ @@ -456,8 +463,8 @@ async def test_check_out_of_sync_github_format( upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=upstream_content, @@ -498,7 +505,7 @@ async def test_check_in_sync_json_format( fs: FakeFilesystem, capsys, configure_logging, - httpx2_mock, + respx_mock: respx.MockRouter, ): """Ensure JSON formatter reports success and no errors when configs are in sync.""" local_content = """ @@ -510,8 +517,8 @@ async def test_check_in_sync_json_format( source_path = pathlib.Path("pyproject.toml") upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=local_content, @@ -555,7 +562,7 @@ async def test_check_in_sync_github_format( fs: FakeFilesystem, capsys, configure_logging, - httpx2_mock, + respx_mock: respx.MockRouter, ): """Ensure GitHub formatter does not emit ::error lines when configs are in sync.""" local_content = """ @@ -567,8 +574,8 @@ async def test_check_in_sync_github_format( source_path = pathlib.Path("pyproject.toml") upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=local_content, @@ -600,7 +607,9 @@ async def test_check_in_sync_github_format( @pytest.mark.asyncio -async def test_check_upstream_error_returns_4(fs: FakeFilesystem, capsys, httpx2_mock): +async def test_check_upstream_error_returns_4( + fs: FakeFilesystem, capsys, respx_mock: respx.MockRouter +): """Verify that an unreachable upstream URL causes check() to raise UpstreamError. The CLI catches UpstreamError and returns exit code 4. Here we test check() @@ -614,8 +623,8 @@ async def test_check_upstream_error_returns_4(fs: FakeFilesystem, capsys, httpx2 upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond(404) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(404) args = ruff_sync.Arguments( command="check", @@ -632,7 +641,9 @@ async def test_check_upstream_error_returns_4(fs: FakeFilesystem, capsys, httpx2 @pytest.mark.asyncio -async def test_check_sarif_format(fs: FakeFilesystem, capsys, configure_logging, httpx2_mock): +async def test_check_sarif_format( + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter +): """Verify --output-format sarif produces a valid SARIF v2.1.0 document.""" import json @@ -643,8 +654,8 @@ async def test_check_sarif_format(fs: FakeFilesystem, capsys, configure_logging, upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=upstream_content, @@ -714,7 +725,7 @@ async def test_check_sarif_format(fs: FakeFilesystem, capsys, configure_logging, @pytest.mark.asyncio async def test_check_sarif_format_in_sync( - fs: FakeFilesystem, capsys, configure_logging, httpx2_mock + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter ): """Verify SARIF formatter emits zero results when configs are in sync.""" import json @@ -725,8 +736,8 @@ async def test_check_sarif_format_in_sync( upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=local_content, @@ -756,7 +767,7 @@ async def test_check_sarif_format_in_sync( @pytest.mark.asyncio async def test_check_sarif_multiple_drifts( - fs: FakeFilesystem, capsys, configure_logging, httpx2_mock + fs: FakeFilesystem, capsys, configure_logging, respx_mock: respx.MockRouter ): """Verify SARIF output includes multiple results for multiple drifted keys.""" import json @@ -768,8 +779,8 @@ async def test_check_sarif_multiple_drifts( upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=upstream_content, diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index bb1f46a..563cdc5 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -28,6 +28,7 @@ ) if TYPE_CHECKING: + import respx from pyfakefs.fake_filesystem import FakeFilesystem from tests.conftest import CLIRunner @@ -518,7 +519,7 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st async def test_pull_aborts_on_invalid_config_when_validate_is_true( fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch, - httpx2_mock, + respx_mock: respx.MockRouter, ) -> None: """When --validate is passed and ruff rejects the config, pull() returns 1. @@ -537,8 +538,8 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st monkeypatch.setattr("ruff_sync.validation.subprocess.run", fake_run) - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond( 200, content_type="text/plain", content=_INVALID_UPSTREAM ) @@ -563,7 +564,7 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st async def test_pull_skips_validation_by_default( fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch, - httpx2_mock, + respx_mock: respx.MockRouter, ) -> None: """Regression guard: without --validate, pull() succeeds even with a bad upstream key. @@ -581,10 +582,8 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st fs.create_file("pyproject.toml", contents=_LOCAL_PYPROJECT) source_path = pathlib.Path("pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( - 200, content_type="text/plain", content=_VALID_UPSTREAM - ) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, content_type="text/plain", content=_VALID_UPSTREAM) args = ruff_sync.Arguments( command="pull", @@ -606,7 +605,7 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st async def test_pull_succeeds_when_validate_passes( fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch, - httpx2_mock, + respx_mock: respx.MockRouter, ) -> None: """When --validate is passed and ruff accepts the config, pull() succeeds (exit 0).""" fs.create_file("pyproject.toml", contents=_LOCAL_PYPROJECT) @@ -618,10 +617,8 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st monkeypatch.setattr("ruff_sync.validation.subprocess.run", fake_run) - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( - 200, content_type="text/plain", content=_VALID_UPSTREAM - ) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, content_type="text/plain", content=_VALID_UPSTREAM) args = ruff_sync.Arguments( command="pull", @@ -915,7 +912,7 @@ def test_pull_uses_persisted_validation_settings( ruff_exit_code: int, expected_exit_code: int, expected_msg: str | None, - httpx2_mock, + respx_mock: respx.MockRouter, ) -> None: """Pull must honor validation settings defined in [tool.ruff-sync].""" fs.create_file( @@ -933,10 +930,8 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st monkeypatch.setattr("ruff_sync.validation.subprocess.run", fake_run) - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( - 200, content_type="text/plain", content="[tool.ruff]\n" - ) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, content_type="text/plain", content="[tool.ruff]\n") with caplog.at_level(logging.ERROR, logger="ruff_sync.validation"): exit_code, _, _ = cli_run(["pull"]) @@ -971,7 +966,7 @@ def test_pull_save_persists_validation_flags( cli_flags: list[str], expected_in_toml: list[str], expected_not_in_toml: list[str], - httpx2_mock, + respx_mock: respx.MockRouter, ) -> None: """Pull --save with validation flags must persist them to [tool.ruff-sync].""" fs.create_file( @@ -986,10 +981,8 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st monkeypatch.setattr("ruff_sync.validation.subprocess.run", fake_run) - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( - 200, content_type="text/plain", content="[tool.ruff]\n" - ) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, content_type="text/plain", content="[tool.ruff]\n") # Run with --save and the parametrized flags exit_code, _, _ = cli_run(["pull", "--save", *cli_flags]) @@ -1007,7 +1000,7 @@ def test_pull_save_clears_validation_flags( cli_run: CLIRunner, clean_config_cache: None, monkeypatch: pytest.MonkeyPatch, - httpx2_mock, + respx_mock: respx.MockRouter, ) -> None: """Pull --no-strict --save must persist 'false' to [tool.ruff-sync].""" fs.create_file( @@ -1025,10 +1018,8 @@ def test_pull_save_clears_validation_flags( "ruff_sync.validation.validate_ruff_accepts_config", lambda *args, **kwargs: True ) - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( - 200, content_type="text/plain", content="[tool.ruff]\n" - ) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, content_type="text/plain", content="[tool.ruff]\n") # Run with --no-strict --save exit_code, _, _ = cli_run(["pull", "--no-strict", "--save"]) diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py index d823862..88413a2 100644 --- a/tests/test_deprecation.py +++ b/tests/test_deprecation.py @@ -12,6 +12,7 @@ import ruff_sync.cli if TYPE_CHECKING: + import respx from pyfakefs.fake_filesystem import FakeFilesystem @@ -20,7 +21,7 @@ def test_source_cli_deprecation( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, clear_ruff_sync_caches, - httpx2_mock, + respx_mock: respx.MockRouter, ): """Test that --source CLI argument emits a deprecation warning and works as --to.""" # Ensure we are in a clean directory @@ -32,10 +33,8 @@ def test_source_cli_deprecation( source_path = test_dir / "pyproject.toml" upstream_url = URL("https://example.com/pyproject.toml") - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond( - 200, text="[tool.ruff]\ntarget-version = 'py310'\n" - ) + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\ntarget-version = 'py310'\n") monkeypatch.setattr(sys, "argv", ["ruff-sync", "pull", str(upstream_url), "--source", "."]) with caplog.at_level(logging.WARNING, logger="ruff_sync"): @@ -51,7 +50,7 @@ def test_source_config_deprecation( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, clear_ruff_sync_caches, - httpx2_mock, + respx_mock: respx.MockRouter, ): """Test that source in [tool.ruff-sync] emits a deprecation warning and that `to` takes precedence. @@ -112,8 +111,8 @@ def test_source_config_deprecation( ) # Mock the upstream request - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\n") + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\n") # No --to or --source on CLI, but use --init to allow creating the sub-project file monkeypatch.setattr(sys, "argv", ["ruff-sync", "pull", "--init"]) @@ -141,8 +140,8 @@ def test_source_config_deprecation( ruff_sync.get_config.cache_clear() monkeypatch.setattr(sys, "argv", ["ruff-sync", "pull"]) - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\n") + with respx_mock(base_url="https://example.com") as mock: + mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\n") exit_code = ruff_sync.main() assert exit_code == 0 diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 5764e28..9d85dce 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -33,7 +33,7 @@ TEST_ROOT: Final = pathlib.Path(__file__).parent -def test_manpage(httpx2_mock): +def test_manpage(respx_mock: respx.MockRouter): """Test that the manpage can be generated with ruff-sync --help with no errors.""" completed = subprocess.run( [sys.executable, "-m", "ruff_sync", "--help"], @@ -66,7 +66,7 @@ class _PrepEnv(NamedTuple): def prep_env( fs: FakeFilesystem, request: FixtureRequest, - httpx2_mock, + respx_mock: respx.MockRouter, ) -> Generator[_PrepEnv, None, None]: group_name: str = request.param fs.add_real_directory(LIFECYCLE_TOML_DIR) @@ -80,8 +80,8 @@ def prep_env( base_url = "https://example.com" upstream_url = URL(f"{base_url}/pyproject.toml") - with httpx2_mock(base_url=base_url, assert_all_called=False) as respx_mock: - respx_mock.get(upstream_url.path).respond( + with respx_mock(base_url=base_url, assert_all_called=False) as mock: + mock.get(upstream_url.path).respond( 200, content_type="text/plain", content=LIFECYCLE_TOML_DIR.joinpath(f"{group_name}_upstream.toml").read_text(), @@ -113,7 +113,7 @@ async def test_ruff_sync(prep_env): @pytest.mark.asyncio -async def test_ruff_check(prep_env, httpx2_mock): +async def test_ruff_check(prep_env: _PrepEnv, respx_mock: respx.MockRouter): # 1. Initially it should be out of sync exit_code = await ruff_sync.check( ruff_sync.Arguments( @@ -178,7 +178,7 @@ async def test_ruff_check(prep_env, httpx2_mock): @pytest.fixture def readme_excludes_env( fs: FakeFilesystem, - httpx2_mock, + respx_mock: respx.MockRouter, ) -> Generator[_PrepEnv, None, None]: group_name = "readme_excludes" fs.add_real_directory(LIFECYCLE_TOML_DIR) @@ -192,8 +192,8 @@ def readme_excludes_env( base_url = "https://example.com" upstream_url = URL(f"{base_url}/pyproject.toml") - with httpx2_mock(base_url=base_url, assert_all_called=False) as respx_mock: - respx_mock.get(upstream_url.path).respond( + with respx_mock(base_url=base_url, assert_all_called=False) as mock: + mock.get(upstream_url.path).respond( 200, content_type="text/plain", content=LIFECYCLE_TOML_DIR.joinpath(f"{group_name}_upstream.toml").read_text(), @@ -232,7 +232,7 @@ async def test_readme_exclude_examples(readme_excludes_env): @pytest.mark.asyncio -async def test_ruff_sync_multi_upstream(fs: FakeFilesystem, httpx2_mock): +async def test_ruff_sync_multi_upstream(fs: FakeFilesystem, respx_mock: respx.MockRouter): """Test merging of multiple upstreams sequentially.""" fs.add_real_directory(LIFECYCLE_TOML_DIR) @@ -246,8 +246,8 @@ async def test_ruff_sync_multi_upstream(fs: FakeFilesystem, httpx2_mock): u2_url = URL("https://example.com/up2.toml") expected_toml = LIFECYCLE_TOML_DIR.joinpath("multi_upstream_final.toml").read_text() - with httpx2_mock(base_url="https://example.com") as respx_mock: - respx_mock.get("/up1.toml").respond( + with respx_mock(base_url="https://example.com") as mock: + mock.get("/up1.toml").respond( 200, content_type="text/plain", content=LIFECYCLE_TOML_DIR.joinpath("multi_upstream_up1.toml").read_text(), diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index 96e1b38..e406165 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -16,19 +16,18 @@ @pytest.fixture -def mock_http(toml_s: str, httpx2_mock) -> Generator[respx.MockRouter, None, None]: - with httpx2_mock(base_url="https://example.com/", assert_all_called=False) as respx_mock: - respx_mock.get("/pyproject.toml").respond( - 200, - content_type="text/plain", - content=toml_s, - ) - respx_mock.get("/ruff.toml").respond( - 200, - content_type="text/plain", - content='target-version = "py310"\nline-length = 99\n', - ) - yield respx_mock +def mock_http(toml_s: str, respx_mock: respx.MockRouter) -> Generator[respx.MockRouter, None, None]: + respx_mock.get("https://example.com/pyproject.toml").respond( + 200, + content_type="text/plain", + content=toml_s, + ) + respx_mock.get("https://example.com/ruff.toml").respond( + 200, + content_type="text/plain", + content='target-version = "py310"\nline-length = 99\n', + ) + yield respx_mock @pytest.fixture diff --git a/tests/test_url_handling.py b/tests/test_url_handling.py index 9a09c61..cb0074a 100644 --- a/tests/test_url_handling.py +++ b/tests/test_url_handling.py @@ -1,11 +1,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import httpx2 as httpx import pytest from httpx2 import URL, AsyncClient from ruff_sync import fetch_upstream_config, is_ruff_toml_file, resolve_raw_url, to_git_url +if TYPE_CHECKING: + import respx + @pytest.mark.parametrize( "path_or_url,expected", @@ -208,7 +213,7 @@ def test_to_git_url(input_url: str, expected_git_url: str | None): @pytest.mark.asyncio -async def test_fetch_upstream_config_with_ruff_toml_fallback(respx_mock): +async def test_fetch_upstream_config_with_ruff_toml_fallback(respx_mock: respx.MockRouter): # Given a directory guess result that would normally point to pyproject.toml # If pyproject.toml does not exist but ruff.toml does, it should find ruff.toml base_url = "https://raw.githubusercontent.com/org/repo/main/configs" From c773860a763a9985d9d340e9ce654f3f07afaabc Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 25 Jul 2026 15:54:37 -0400 Subject: [PATCH 13/13] refactor(tests): rename inner mock variable to http_mock --- tests/test_basic.py | 10 ++--- tests/test_check.py | 66 ++++++++++++++++----------------- tests/test_config_validation.py | 34 +++++++++++------ tests/test_deprecation.py | 14 ++++--- tests/test_e2e.py | 12 +++--- tests/test_scaffold.py | 23 ++++++------ 6 files changed, 86 insertions(+), 73 deletions(-) diff --git a/tests/test_basic.py b/tests/test_basic.py index c38b72a..36f5d47 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -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 @@ -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, diff --git a/tests/test_check.py b/tests/test_check.py index 67fa70c..bab9e98 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -26,8 +26,8 @@ async def test_check_in_sync(fs: FakeFilesystem, respx_mock: respx.MockRouter): upstream_url = URL("https://example.com/pyproject.toml") - 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=pyproject_content, @@ -65,8 +65,8 @@ async def test_check_out_of_sync( upstream_url = URL("https://example.com/pyproject.toml") - 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_content, @@ -115,8 +115,8 @@ async def test_check_pre_commit_out_of_sync( upstream_url = URL("https://example.com/pyproject.toml") - 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=local_content, @@ -160,8 +160,8 @@ async def test_check_semantic_sync(fs: FakeFilesystem, respx_mock: respx.MockRou upstream_url = URL("https://example.com/pyproject.toml") - 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_content, @@ -208,8 +208,8 @@ async def test_check_semantic_out_of_sync(fs: FakeFilesystem, respx_mock: respx. upstream_url = URL("https://example.com/pyproject.toml") - 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_content, @@ -245,8 +245,8 @@ async def test_check_semantic_diff_output( upstream_url = URL("https://example.com/pyproject.toml") - 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_content, @@ -305,9 +305,9 @@ async def test_check_multi_upstream( u1_url = URL("https://example.com/u1/pyproject.toml") u2_url = URL("https://example.com/u2/pyproject.toml") - with respx_mock(base_url="https://example.com") as mock: - mock.get("/u1/pyproject.toml").respond(200, content=upstream1_content) - mock.get("/u2/pyproject.toml").respond(200, content=upstream2_content) + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/u1/pyproject.toml").respond(200, content=upstream1_content) + http_mock.get("/u2/pyproject.toml").respond(200, content=upstream2_content) args = ruff_sync.Arguments( command="check", @@ -354,8 +354,8 @@ async def test_check_both_out_of_sync_prioritizes_config_drift( upstream_url = URL("https://example.com/pyproject.toml") - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, content=upstream_content) + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond(200, content=upstream_content) args = ruff_sync.Arguments( command="check", @@ -398,8 +398,8 @@ async def test_check_out_of_sync_json_format( upstream_url = URL("https://example.com/pyproject.toml") - 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_content, @@ -463,8 +463,8 @@ async def test_check_out_of_sync_github_format( upstream_url = URL("https://example.com/pyproject.toml") - 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_content, @@ -517,8 +517,8 @@ async def test_check_in_sync_json_format( source_path = pathlib.Path("pyproject.toml") upstream_url = URL("https://example.com/pyproject.toml") - 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=local_content, @@ -574,8 +574,8 @@ async def test_check_in_sync_github_format( source_path = pathlib.Path("pyproject.toml") upstream_url = URL("https://example.com/pyproject.toml") - 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=local_content, @@ -623,8 +623,8 @@ async def test_check_upstream_error_returns_4( upstream_url = URL("https://example.com/pyproject.toml") - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(404) + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond(404) args = ruff_sync.Arguments( command="check", @@ -654,8 +654,8 @@ async def test_check_sarif_format( upstream_url = URL("https://example.com/pyproject.toml") - 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_content, @@ -736,8 +736,8 @@ async def test_check_sarif_format_in_sync( upstream_url = URL("https://example.com/pyproject.toml") - 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=local_content, @@ -779,8 +779,8 @@ async def test_check_sarif_multiple_drifts( upstream_url = URL("https://example.com/pyproject.toml") - 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_content, diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index 563cdc5..0e07a16 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -538,8 +538,8 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st monkeypatch.setattr("ruff_sync.validation.subprocess.run", fake_run) - 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=_INVALID_UPSTREAM ) @@ -582,8 +582,10 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st fs.create_file("pyproject.toml", contents=_LOCAL_PYPROJECT) source_path = pathlib.Path("pyproject.toml") - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, content_type="text/plain", content=_VALID_UPSTREAM) + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond( + 200, content_type="text/plain", content=_VALID_UPSTREAM + ) args = ruff_sync.Arguments( command="pull", @@ -617,8 +619,10 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st monkeypatch.setattr("ruff_sync.validation.subprocess.run", fake_run) - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, content_type="text/plain", content=_VALID_UPSTREAM) + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond( + 200, content_type="text/plain", content=_VALID_UPSTREAM + ) args = ruff_sync.Arguments( command="pull", @@ -930,8 +934,10 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st monkeypatch.setattr("ruff_sync.validation.subprocess.run", fake_run) - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, content_type="text/plain", content="[tool.ruff]\n") + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond( + 200, content_type="text/plain", content="[tool.ruff]\n" + ) with caplog.at_level(logging.ERROR, logger="ruff_sync.validation"): exit_code, _, _ = cli_run(["pull"]) @@ -981,8 +987,10 @@ def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[st monkeypatch.setattr("ruff_sync.validation.subprocess.run", fake_run) - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, content_type="text/plain", content="[tool.ruff]\n") + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond( + 200, content_type="text/plain", content="[tool.ruff]\n" + ) # Run with --save and the parametrized flags exit_code, _, _ = cli_run(["pull", "--save", *cli_flags]) @@ -1018,8 +1026,10 @@ def test_pull_save_clears_validation_flags( "ruff_sync.validation.validate_ruff_accepts_config", lambda *args, **kwargs: True ) - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, content_type="text/plain", content="[tool.ruff]\n") + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond( + 200, content_type="text/plain", content="[tool.ruff]\n" + ) # Run with --no-strict --save exit_code, _, _ = cli_run(["pull", "--no-strict", "--save"]) diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py index 88413a2..d7a62c9 100644 --- a/tests/test_deprecation.py +++ b/tests/test_deprecation.py @@ -33,8 +33,10 @@ def test_source_cli_deprecation( source_path = test_dir / "pyproject.toml" upstream_url = URL("https://example.com/pyproject.toml") - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\ntarget-version = 'py310'\n") + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond( + 200, text="[tool.ruff]\ntarget-version = 'py310'\n" + ) monkeypatch.setattr(sys, "argv", ["ruff-sync", "pull", str(upstream_url), "--source", "."]) with caplog.at_level(logging.WARNING, logger="ruff_sync"): @@ -111,8 +113,8 @@ def test_source_config_deprecation( ) # Mock the upstream request - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\n") + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\n") # No --to or --source on CLI, but use --init to allow creating the sub-project file monkeypatch.setattr(sys, "argv", ["ruff-sync", "pull", "--init"]) @@ -140,8 +142,8 @@ def test_source_config_deprecation( ruff_sync.get_config.cache_clear() monkeypatch.setattr(sys, "argv", ["ruff-sync", "pull"]) - with respx_mock(base_url="https://example.com") as mock: - mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\n") + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/pyproject.toml").respond(200, text="[tool.ruff]\n") exit_code = ruff_sync.main() assert exit_code == 0 diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9d85dce..ce0512e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -80,8 +80,8 @@ def prep_env( base_url = "https://example.com" upstream_url = URL(f"{base_url}/pyproject.toml") - with respx_mock(base_url=base_url, assert_all_called=False) as mock: - mock.get(upstream_url.path).respond( + with respx_mock(base_url=base_url, assert_all_called=False) as http_mock: + http_mock.get(upstream_url.path).respond( 200, content_type="text/plain", content=LIFECYCLE_TOML_DIR.joinpath(f"{group_name}_upstream.toml").read_text(), @@ -192,8 +192,8 @@ def readme_excludes_env( base_url = "https://example.com" upstream_url = URL(f"{base_url}/pyproject.toml") - with respx_mock(base_url=base_url, assert_all_called=False) as mock: - mock.get(upstream_url.path).respond( + with respx_mock(base_url=base_url, assert_all_called=False) as http_mock: + http_mock.get(upstream_url.path).respond( 200, content_type="text/plain", content=LIFECYCLE_TOML_DIR.joinpath(f"{group_name}_upstream.toml").read_text(), @@ -246,8 +246,8 @@ async def test_ruff_sync_multi_upstream(fs: FakeFilesystem, respx_mock: respx.Mo u2_url = URL("https://example.com/up2.toml") expected_toml = LIFECYCLE_TOML_DIR.joinpath("multi_upstream_final.toml").read_text() - with respx_mock(base_url="https://example.com") as mock: - mock.get("/up1.toml").respond( + with respx_mock(base_url="https://example.com") as http_mock: + http_mock.get("/up1.toml").respond( 200, content_type="text/plain", content=LIFECYCLE_TOML_DIR.joinpath("multi_upstream_up1.toml").read_text(), diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index e406165..1b864b6 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -17,17 +17,18 @@ @pytest.fixture def mock_http(toml_s: str, respx_mock: respx.MockRouter) -> Generator[respx.MockRouter, None, None]: - respx_mock.get("https://example.com/pyproject.toml").respond( - 200, - content_type="text/plain", - content=toml_s, - ) - respx_mock.get("https://example.com/ruff.toml").respond( - 200, - content_type="text/plain", - content='target-version = "py310"\nline-length = 99\n', - ) - yield respx_mock + with respx_mock(base_url="https://example.com/", assert_all_called=False) as http_mock: + http_mock.get("/pyproject.toml").respond( + 200, + content_type="text/plain", + content=toml_s, + ) + http_mock.get("/ruff.toml").respond( + 200, + content_type="text/plain", + content='target-version = "py310"\nline-length = 99\n', + ) + yield http_mock @pytest.fixture