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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion src/aperiodic/_backends/_pyfetch_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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 "
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions tests/test_pyfetch_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from __future__ import annotations

import asyncio
import json
import sys
from unittest import mock

import pytest
Expand All @@ -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."""
Expand Down Expand Up @@ -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", {}, {}))
Loading