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
4 changes: 2 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 27 additions & 14 deletions src/httpx2/httpx2/_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Comment thread
Kludex marked this conversation as resolved.

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)
Comment thread
Kludex marked this conversation as resolved.
43 changes: 36 additions & 7 deletions tests/httpx2/test_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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"]
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading