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
2 changes: 2 additions & 0 deletions pixi.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file added py.typed
Empty file.
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ dependencies = [
"aiolimiter>=1.2,<2.0",
"hishel>=1.3,<2.0",
"loguru>=0.7.3,<1.0.0",
"pydantic>=2.13,<3.0",
"anysqlite>=0.0.5,<0.0.6",
]

[project.urls]
Expand All @@ -48,5 +50,5 @@ version_file = "reqadence/_version.py"
version_scheme = "guess-next-dev"
local_scheme = "node-and-timestamp"

[tool.basedpyright]
typeCheckingMode = "standard"
[tool.setuptools.package-data]
"reqadence" = ["py.typed"]
19 changes: 9 additions & 10 deletions reqadence/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import sys
from ast import literal_eval
from importlib.metadata import PackageNotFoundError, version
from typing import Any
from typing import cast

from loguru import logger

Expand Down Expand Up @@ -40,9 +40,9 @@ def enable_logging(
level: Requested log level: `10` is debug, `20` is info.
file_path: Also write logs to files here.
"""
config: dict[str, Any] = {"handlers": []}
handlers: list[dict[str, object]] = []
if stdout_set:
config["handlers"].append(
handlers.append(
{
"sink": sys.stdout,
"level": level_set,
Expand All @@ -51,22 +51,21 @@ def enable_logging(
}
)
if isinstance(file_path, str):
config["handlers"].append(
handlers.append(
{
"sink": file_path,
"level": level_set,
"format": log_format,
"colorize": colorize,
}
)
_ = logger.configure(handlers=handlers) # pyright: ignore[reportArgumentType]
# https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.configure
logger.configure(**config)

logger.enable("reqadence")


if literal_eval(os.environ.get("REQADENCE_LOG", "False")):
level = int(os.environ.get("REQADENCE_LOG_LEVEL", 20))
stdout = literal_eval(os.environ.get("REQADENCE_STDOUT", "True"))
log_file_path = os.environ.get("REQADENCE_LOG_FILE_PATH", None)
if cast(bool, literal_eval(os.environ.get("REQADENCE_LOG", "False"))):
level = int(os.environ.get("REQADENCE_LOG_LEVEL") or "20")
stdout = cast(bool, literal_eval(os.environ.get("REQADENCE_STDOUT") or "True"))
log_file_path = os.environ.get("REQADENCE_LOG_FILE_PATH")
enable_logging(level, stdout, log_file_path)
14 changes: 9 additions & 5 deletions reqadence/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from dataclasses import dataclass, field
from enum import StrEnum
from types import TracebackType
from typing import Self
from typing import Self, cast, override

import httpx
from aiolimiter import AsyncLimiter
Expand Down Expand Up @@ -156,13 +156,16 @@ def _retry_delay(self, resp: httpx.Response | None, attempt: int) -> float:
Returns:
The delay in seconds before the next retry attempt.
"""
if resp is not None and (value := resp.headers.get("retry-after")):
retry_after: str | None = (
resp.headers.get("retry-after") if resp is not None else None
)
if retry_after is not None:
try:
return float(value)
return float(retry_after)
except ValueError:
logger.warning(
"Invalid Retry-After header value: {value}. Falling back to backoff delay.",
value=value,
value=retry_after,
)
return self.config.retry_policy.backoff_delay(attempt)

Expand Down Expand Up @@ -266,7 +269,7 @@ async def _get_json(
"""GET and parse JSON, returning None on failure."""
resp = await self._get(url, params=params)
try:
return resp.json()
return cast(JSONValue, resp.json())
except ValueError:
logger.error("Failed to parse JSON from {url}", url=url)
return None
Expand All @@ -288,6 +291,7 @@ async def __aexit__(
"""Ensure the underlying http client is closed when exiting the async context manager."""
await self.aclose()

@override
def __repr__(self) -> str:
"""Return the class and base URL for debugging purposes."""
return f"{self.__class__.__name__}(base_url={self.base_url!r})"
18 changes: 12 additions & 6 deletions reqadence/api/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from typing import Any, override

import httpx
from hishel import (
Expand Down Expand Up @@ -42,7 +42,7 @@ class CachePolicy(ABC):
Default is .cache/hishel/hishel_cache.db"""

@abstractmethod
def _build_hishel_policy(self) -> Any:
def _build_hishel_policy(self) -> SpecificationPolicy | FilterPolicy:
"""Return the hishel policy object for this strategy."""
raise NotImplementedError()

Expand All @@ -66,13 +66,13 @@ def build_client_factory(
default_ttl=self.default_ttl,
)

def factory(**kwargs: Any) -> httpx.AsyncClient:
def factory(**kwargs: Any) -> httpx.AsyncClient: # pyright: ignore[reportAny, reportExplicitAny]
transport = AsyncCacheTransport(
next_transport=next_transport or httpx.AsyncHTTPTransport(),
storage=storage,
policy=cache_policy,
)
return httpx.AsyncClient(transport=transport, **kwargs)
return httpx.AsyncClient(transport=transport, **kwargs) # pyright: ignore[reportAny]

return factory

Expand All @@ -85,7 +85,8 @@ class RFCCachePolicy(CachePolicy):
Use for APIs that send proper cache headers.
"""

def _build_hishel_policy(self) -> Any:
@override
def _build_hishel_policy(self) -> SpecificationPolicy:

return SpecificationPolicy(
cache_options=CacheOptions(supported_methods=list(self.supported_methods))
Expand All @@ -98,25 +99,30 @@ class AlwaysCachePolicy(CachePolicy):

cacheable_status_codes: frozenset[int] = frozenset({200})

def _build_hishel_policy(self) -> Any:
@override
def _build_hishel_policy(self) -> FilterPolicy:

methods = frozenset(m.upper() for m in self.supported_methods)
codes = frozenset(self.cacheable_status_codes)

class _MethodFilter(BaseFilter[Request]):
@override
def needs_body(self) -> bool:
"""This filter does not require the request body."""
return False

@override
def apply(self, item: Request, body: bytes | None) -> bool:
"""Cache only if the HTTP method is in the supported methods set."""
return item.method.upper() in methods

class _StatusFilter(BaseFilter[Response]):
@override
def needs_body(self) -> bool:
"""This filter does not require the response body."""
return False

@override
def apply(self, item: Response, body: bytes | None) -> bool:
"""
Cache only if the HTTP status code is in the cacheable
Expand Down
4 changes: 2 additions & 2 deletions reqadence/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
class APIError(Exception):
def __init__(self, url: str, status_code: int | None = None) -> None:
"""Base class for API failures, distinguishing retryable errors from permanent ones."""
self.url = url
self.url: str = url
"""The URL of the API endpoint."""
self.status_code = status_code
self.status_code: int | None = status_code
"""The HTTP status code of the API response, if available."""
message = f"{url} (HTTP {status_code})" if status_code else url
super().__init__(message)
Expand Down
Loading
Loading