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", {}, {}))