diff --git a/docs/migration.md b/docs/migration.md index 990c7fd0..968371e0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -93,11 +93,11 @@ import httpx2 httpx2.alias_httpx() ``` -After this call, `import httpx` resolves to `httpx2`, process-wide. Submodule imports included. Dependencies importing `httpx` get the `httpx2` classes, so `isinstance()` checks pass and `except` clauses catch across the boundary. You can pass your `httpx2.Client` anywhere and drop the `httpx` install entirely. +After this call, `import httpx` resolves to `httpx2` and `import httpcore` resolves to `httpcore2`, process-wide. Submodule imports included. Dependencies importing `httpx` get the `httpx2` classes, so `isinstance()` checks pass and `except` clauses catch across the boundary - and the same holds for dependencies importing `httpcore` directly, such as custom transports catching low-level exceptions. You can pass your `httpx2.Client` anywhere and drop the `httpx` and `httpcore` installs entirely. There are exactly two rules: -* **Call it first.** It must run at the very top of your entrypoint, before anything imports `httpx`. Modules that already imported `httpx` hold references the alias can't rewrite, so `alias_httpx()` raises a `RuntimeError` instead of half-working. +* **Call it first.** It must run at the very top of your entrypoint, before anything imports `httpx` or `httpcore`. Modules that already imported them hold references the alias can't rewrite, so `alias_httpx()` raises a `RuntimeError` instead of half-working. * **Applications only, never libraries.** It changes the meaning of `import httpx` for the whole process. That is an application's decision to make, not something a library should impose on its users. !!! warning diff --git a/src/httpx2/httpx2/_alias.py b/src/httpx2/httpx2/_alias.py index a21ae8ea..111e2cf4 100644 --- a/src/httpx2/httpx2/_alias.py +++ b/src/httpx2/httpx2/_alias.py @@ -14,8 +14,11 @@ class _AliasLoader(importlib.abc.Loader): _original_spec: importlib.machinery.ModuleSpec | None + def __init__(self, real_name: str) -> None: + self._real_name = real_name + def create_module(self, spec: importlib.machinery.ModuleSpec) -> ModuleType: - module = importlib.import_module("httpx2" + spec.name.removeprefix("httpx")) + module = importlib.import_module(self._real_name) self._original_spec = module.__spec__ return module @@ -24,35 +27,45 @@ def exec_module(self, module: ModuleType) -> None: class _AliasFinder(importlib.abc.MetaPathFinder): + def __init__(self, alias: str, real: str) -> None: + self._alias = alias + self._real = real + def find_spec( self, fullname: str, path: Sequence[str] | None = None, target: ModuleType | None = None, ) -> importlib.machinery.ModuleSpec | None: - if fullname != "httpx" and not fullname.startswith("httpx."): + if fullname != self._alias and not fullname.startswith(self._alias + "."): return None - real_name = "httpx2" + fullname.removeprefix("httpx") + real_name = self._real + fullname.removeprefix(self._alias) if real_name not in sys.modules and importlib.util.find_spec(real_name) is None: return None - return importlib.machinery.ModuleSpec(fullname, _AliasLoader()) + return importlib.machinery.ModuleSpec(fullname, _AliasLoader(real_name)) + + +def _alias(alias: str, module: ModuleType) -> None: + existing = sys.modules.get(alias) + if existing is not None and existing is not module: + raise RuntimeError(f"{alias} was already imported; call `alias_httpx()` before any `import {alias}`.") + + if not any(isinstance(finder, _AliasFinder) and finder._alias == alias for finder in sys.meta_path): + sys.meta_path.insert(0, _AliasFinder(alias, module.__name__)) + sys.modules[alias] = module def alias_httpx() -> None: """ - Make `import httpx` resolve to `httpx2`, process-wide. + Make `import httpx` resolve to `httpx2`, and `import httpcore` to `httpcore2`, process-wide. Intended for applications migrating from `httpx`, so that dependencies still - importing `httpx` share the `httpx2` classes. Libraries should never call this. + importing `httpx` or `httpcore` share the `httpx2` classes. Libraries should never call this. - Must be called before anything imports `httpx`. Calling it again is a no-op. + Must be called before anything imports `httpx` or `httpcore`. Calling it again is a no-op. """ + import httpcore2 import httpx2 - existing = sys.modules.get("httpx") - if existing is not None and existing is not httpx2: - raise RuntimeError("httpx was already imported; call `alias_httpx()` before any `import httpx`.") - - if not any(isinstance(finder, _AliasFinder) for finder in sys.meta_path): - sys.meta_path.insert(0, _AliasFinder()) - sys.modules["httpx"] = httpx2 + _alias("httpx", httpx2) + _alias("httpcore", httpcore2) diff --git a/tests/httpx2/test_alias.py b/tests/httpx2/test_alias.py index 2608dedf..4b978543 100644 --- a/tests/httpx2/test_alias.py +++ b/tests/httpx2/test_alias.py @@ -8,19 +8,22 @@ import pytest +import httpcore2 import httpx2 from httpx2._alias import _AliasFinder +def _is_aliased(name: str) -> bool: + return name.partition(".")[0] in ("httpx", "httpcore") + + @pytest.fixture(autouse=True) def restore_import_state() -> Iterator[None]: saved_meta_path = list(sys.meta_path) - saved_modules = { - name: module for name, module in sys.modules.items() if name == "httpx" or name.startswith("httpx.") - } + saved_modules = {name: module for name, module in sys.modules.items() if _is_aliased(name)} yield sys.meta_path[:] = saved_meta_path - for name in [name for name in sys.modules if name == "httpx" or name.startswith("httpx.")]: + for name in [name for name in sys.modules if _is_aliased(name)]: del sys.modules[name] sys.modules.update(saved_modules) @@ -46,6 +49,24 @@ def test_alias_submodules_share_modules() -> None: raise httpx2.ConnectError("boom") +def test_alias_httpcore_top_level_import() -> None: + httpx2.alias_httpx() + + import httpcore + + assert httpcore is httpcore2 + assert httpcore.ConnectionPool is httpcore2.ConnectionPool + + +def test_alias_httpcore_submodules_share_modules() -> None: + httpx2.alias_httpx() + + from httpcore._exceptions import ConnectError + + assert ConnectError is httpcore2.ConnectError + assert sys.modules["httpcore._exceptions"] is sys.modules["httpcore2._exceptions"] + + def test_alias_finder_handles_top_level_import() -> None: httpx2.alias_httpx() del sys.modules["httpx"] @@ -67,9 +88,10 @@ def test_alias_preserves_canonical_spec() -> None: def test_alias_is_idempotent() -> None: httpx2.alias_httpx() - httpx2.alias_httpx() + assert sum(isinstance(finder, _AliasFinder) for finder in sys.meta_path) == 2 - assert sum(isinstance(finder, _AliasFinder) for finder in sys.meta_path) == 1 + httpx2.alias_httpx() + assert sum(isinstance(finder, _AliasFinder) for finder in sys.meta_path) == 2 def test_alias_raises_if_httpx_already_imported() -> None: @@ -79,8 +101,15 @@ def test_alias_raises_if_httpx_already_imported() -> None: httpx2.alias_httpx() +def test_alias_raises_if_httpcore_already_imported() -> None: + sys.modules["httpcore"] = types.ModuleType("httpcore") + + with pytest.raises(RuntimeError, match="httpcore was already imported"): + httpx2.alias_httpx() + + def test_finder_ignores_other_modules() -> None: - assert _AliasFinder().find_spec("json") is None + assert _AliasFinder("httpx", "httpx2").find_spec("json") is None def test_finder_returns_none_for_missing_submodules() -> None: