From 432387e20a81d2110186996042cff23e8cec07c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:24:57 +0000 Subject: [PATCH] fix(Install): Name the extras when a plain install tries to fetch data A plain `pip install aperiodic` has no HTTP client, so data calls fell to the Pyodide transport and crashed with "No module named 'pyodide'". Outside Pyodide that transport now raises an ImportError naming the extras (aperiodic[polars] or aperiodic[pandas]), the Pyodide check returns False instead of raising, and the README installs an extra first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur --- README.md | 6 +++- src/aperiodic/_backends/_pyfetch_transport.py | 31 ++++++++++++++++++- tests/test_pyfetch_transport.py | 29 +++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ed686f..66ca661 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,14 @@ Access pre-computed derivative and microstructure metrics with parallel download ## Installation +Install it with the data frame library you use. The extra also brings the HTTP client that fetching data needs: + ```bash -pip install aperiodic +pip install "aperiodic[polars]" # or "aperiodic[pandas]" ``` +A plain `pip install aperiodic` is enough to sign in with `aperiodic.login()`, but not to fetch data. + Install from source: ```bash diff --git a/src/aperiodic/_backends/_pyfetch_transport.py b/src/aperiodic/_backends/_pyfetch_transport.py index f7e1d91..8e86830 100644 --- a/src/aperiodic/_backends/_pyfetch_transport.py +++ b/src/aperiodic/_backends/_pyfetch_transport.py @@ -22,6 +22,25 @@ T = TypeVar("T") +NO_HTTP_CLIENT = ( + "Fetching data needs an HTTP client, which a plain `pip install aperiodic` " + "leaves out. Install one with the data frame library you use: " + 'pip install "aperiodic[polars]" or pip install "aperiodic[pandas]".' +) + + +def _in_browser_runtime() -> bool: + """Pyodide, or a test that fakes it by putting its modules in ``sys.modules``.""" + return sys.platform == "emscripten" or any( + name in sys.modules for name in ("pyodide", "pyodide.ffi", "pyodide.http") + ) + + +def _require_browser_runtime() -> None: + """Outside Pyodide this transport is only chosen because httpx is missing.""" + if not _in_browser_runtime(): + raise ImportError(NO_HTTP_CLIENT) + def _has_pyodide_ffi() -> bool: """Detect ``pyodide.ffi`` without importing it or crashing on test mocks. @@ -38,7 +57,11 @@ def _has_pyodide_ffi() -> bool: """ if "pyodide.ffi" in sys.modules: return True - return find_spec("pyodide.ffi") is not None + try: + return find_spec("pyodide.ffi") is not None + except (ModuleNotFoundError, ValueError): + # find_spec imports the parent package, which is not there outside Pyodide. + return False def run_async(coro: Coroutine[None, None, T]) -> T: @@ -61,7 +84,11 @@ def run_async(coro: Coroutine[None, None, T]) -> T: runtime), raise a clear error pointing users to the ``_async`` counterparts rather than silently returning a ``Task``. """ + if not _in_browser_runtime(): + coro.close() + raise ImportError(NO_HTTP_CLIENT) if not _has_pyodide_ffi(): + coro.close() raise RuntimeError( "Synchronous Aperiodic client calls require Pyodide ≥ 0.26 with " "JavaScript Promise Integration (stack switching). Use the " @@ -143,6 +170,7 @@ async def fetch_json( retried with exponential backoff. Every other response is handed to the caller as data or an ``APIError`` on the first attempt. """ + _require_browser_runtime() full_url = _build_url(url, params) for attempt in range(max_retries): @@ -221,6 +249,7 @@ async def download_parquet_bytes( Returns: Tuple of (year, month, raw_bytes) """ + _require_browser_runtime() from pyodide.http import pyfetch # type: ignore[import-not-found] async with semaphore: diff --git a/tests/test_pyfetch_transport.py b/tests/test_pyfetch_transport.py index 649a0bd..efeaa4c 100644 --- a/tests/test_pyfetch_transport.py +++ b/tests/test_pyfetch_transport.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio import json +import sys from unittest import mock import pytest @@ -11,6 +13,8 @@ pa = pytest.importorskip("pyarrow") pq = pytest.importorskip("pyarrow.parquet") +from aperiodic._backends import _pyfetch_transport as pyfetch_transport # noqa: E402 + class FakeResponse: """Minimal mock for pyfetch response.""" @@ -265,3 +269,28 @@ async def test_raises_download_error_after_retries(self): semaphore=asyncio.Semaphore(10), max_retries=0, ) + + +class TestPlainInstall: + """Regular Python with no httpx: the extras are named, never a missing pyodide.""" + + @pytest.fixture(autouse=True) + def _no_pyodide(self, monkeypatch): + for name in ("pyodide", "pyodide.ffi", "pyodide.http"): + monkeypatch.delitem(sys.modules, name, raising=False) + + def test_pyodide_detection_is_false_rather_than_an_error(self): + assert pyfetch_transport._has_pyodide_ffi() is False + + def test_a_sync_call_names_the_extras_to_install(self): + async def fetch(): + return 1 + + coro = fetch() + with pytest.raises(ImportError, match=r"aperiodic\[polars\]"): + pyfetch_transport.run_async(coro) + assert coro.cr_frame is None # closed, so no "never awaited" warning + + def test_an_async_call_names_the_extras_to_install(self): + with pytest.raises(ImportError, match=r"aperiodic\[pandas\]"): + asyncio.run(pyfetch_transport.fetch_json("https://x.test", {}, {}))