Source code for nyora.client

"""Nyora helper HTTP clients.

This module provides the SDK's REST clients. They run no parsers themselves;
they speak the camelCase helper REST contract over HTTP via ``httpx`` against a
Nyora parser engine.

It exposes:

* :class:`Nyora` — synchronous client with the full set of service objects
  (sources, manga, library, downloads, backup, system).
* :class:`AsyncNyora` — asynchronous client with the read/browse surface
  (``sources``/``manga``) plus raw ``get``/``post``/``delete``.

Both clients automatically **retry** transient failures (connect/read timeouts,
connection errors, ``429``/``5xx``) with exponential backoff + jitter, send a
descriptive **User-Agent**, and emit structured logs on the ``"nyora"`` logger.

The SDK is fully self-contained: when no server is configured, :class:`Nyora`
launches its own **bundled parser engine** locally (shipped with
``nyora-extension-server``) and owns its lifecycle — no cloud required. A base
URL can be supplied explicitly, via the ``NYORA_BASE_URL`` environment variable,
via persisted config (``nyora config set-url``), or discovered from a running
helper's port file. A self-hosted helper jar can also be launched and managed
via :meth:`Nyora.managed`.
"""

from __future__ import annotations

import asyncio
import logging
import os
import time
from types import TracebackType
from typing import Any

import httpx
from typing_extensions import Self

from nyora._meta import USER_AGENT
from nyora.config import BASE_URL_ENV, read_base_url_from_config, read_base_url_from_port_file
from nyora.errors import (
    HelperNotFoundError,
    NyoraConnectionError,
    NyoraHTTPError,
    NyoraTimeoutError,
)
from nyora.helper import HelperProcess
from nyora.models import MangaDetails, MangaPage, SearchPage, Source
from nyora.pagers import AsyncMangaPager
from nyora.retry import TRANSIENT_EXCEPTIONS, RetryConfig, retry_after_seconds
from nyora.services.backup import BackupService
from nyora.services.downloads import DownloadsService
from nyora.services.library import LibraryService
from nyora.services.manga import MangaService
from nyora.services.sources import SourcesService
from nyora.services.system import SystemService

logger = logging.getLogger("nyora")

_DEFAULT_HEADERS = {"User-Agent": USER_AGENT, "Accept": "application/json"}


def _resolve_base_url(base_url: str | None) -> str | None:
    """Resolve a configured helper base URL, or ``None`` if none is set.

    Order: explicit argument → ``NYORA_BASE_URL`` env → persisted config
    (``nyora config set-url``) → a running local helper's port file. There is **no
    cloud fallback** — the SDK is fully self-contained: when nothing is configured,
    :class:`Nyora` launches its own bundled parser engine locally.

    Args:
        base_url: Explicit base URL, or ``None`` to auto-discover.

    Returns:
        The resolved base URL (trailing slash removed), or ``None``.
    """
    resolved = (
        base_url
        or os.getenv(BASE_URL_ENV)
        or read_base_url_from_config()
        or read_base_url_from_port_file()
    )
    return resolved.rstrip("/") if resolved else None


def _decode(response: httpx.Response) -> Any:
    """Decode a successful response: JSON, empty dict for no body, else text."""
    if not response.content:
        return {}
    if "application/json" in response.headers.get("content-type", ""):
        return response.json()
    return response.text


def _wrap_transport(exc: Exception) -> NyoraConnectionError | NyoraTimeoutError:
    """Wrap an exhausted httpx transport error in the SDK's typed exception."""
    if isinstance(exc, httpx.TimeoutException):
        return NyoraTimeoutError(f"Request to the Nyora engine timed out: {exc}")
    return NyoraConnectionError(f"Could not reach the Nyora engine: {exc}")


[docs] class Nyora: """Synchronous Nyora SDK client backed by a helper REST API. Wraps an ``httpx.Client`` against a discovered or managed helper and exposes the full set of service objects. Transient failures are retried with exponential backoff. Use as a context manager to release the HTTP connection (and stop a managed helper) on exit. Attributes: base_url: The resolved helper base URL. sources: :class:`~nyora.services.sources.SourcesService`. manga: :class:`~nyora.services.manga.MangaService`. library: :class:`~nyora.services.library.LibraryService`. downloads: :class:`~nyora.services.downloads.DownloadsService`. backup: :class:`~nyora.services.backup.BackupService`. system: :class:`~nyora.services.system.SystemService`. Example: >>> with Nyora.attach() as client: ... for source in client.sources.list(): ... print(source.id, source.name) """ def __init__( self, base_url: str | None = None, *, timeout: float = 60.0, retries: RetryConfig | int | None = None, helper: HelperProcess | None = None, ) -> None: """Connect to a helper and construct the service objects. Args: base_url: Explicit helper base URL, or ``None`` to auto-discover. timeout: Per-request HTTP timeout in seconds. retries: Retry policy — an int (max attempts) or a :class:`~nyora.retry.RetryConfig`. ``0`` disables retrying. helper: An owned :class:`~nyora.helper.HelperProcess` to stop on :meth:`close`, when the client launched the helper itself. Raises: HelperNotFoundError: If no server is configured and no bundled engine can be launched (e.g. no Java runtime / no engine jar). """ explicit = base_url or (helper.base_url if helper else None) resolved = _resolve_base_url(explicit) if resolved is None: # Fully self-contained: no server configured or running → launch the # bundled parser engine locally and own its lifecycle. No cloud. if helper is None: helper = HelperProcess() resolved = helper.start() self.base_url: str = resolved self._helper = helper self._retry = RetryConfig.coerce(retries) self._http = httpx.Client( base_url=self.base_url, timeout=timeout, headers=_DEFAULT_HEADERS ) self.sources = SourcesService(self) self.manga = MangaService(self) self.library = LibraryService(self) self.downloads = DownloadsService(self) self.backup = BackupService(self) self.system = SystemService(self)
[docs] @classmethod def attach( cls, base_url: str | None = None, *, timeout: float = 60.0, retries: RetryConfig | int | None = None, ) -> Self: """Attach to an already-running helper. Args: base_url: Explicit helper base URL, or ``None`` to auto-discover. timeout: Per-request HTTP timeout in seconds. retries: Retry policy (int or :class:`~nyora.retry.RetryConfig`). Returns: A connected client. """ return cls(base_url=base_url, timeout=timeout, retries=retries)
[docs] @classmethod def managed( cls, jar_path: str | os.PathLike[str] | None = None, *, java: str = "java", timeout: float = 60.0, retries: RetryConfig | int | None = None, launch_timeout: float = 20.0, ) -> Self: """Launch a helper jar and return a client bound to it. The launched process is owned by the returned client and is stopped on :meth:`close`. Args: jar_path: Path to the helper jar. When ``None`` it is read from the ``NYORA_HELPER_JAR`` environment variable. java: The ``java`` executable to invoke. timeout: Per-request HTTP timeout in seconds for the client. retries: Retry policy (int or :class:`~nyora.retry.RetryConfig`). launch_timeout: Seconds to wait for the helper to report healthy. Returns: A client connected to the managed helper. Raises: HelperNotFoundError: If the jar path is missing or does not exist. HelperLaunchError: If the helper fails to start within the timeout. """ helper = HelperProcess(jar_path, java=java, timeout=launch_timeout) base_url = helper.start() return cls(base_url=base_url, timeout=timeout, retries=retries, helper=helper)
[docs] def close(self) -> None: """Close the HTTP connection and stop any managed helper process.""" self._http.close() if self._helper is not None: self._helper.stop() self._helper = None
def __enter__(self) -> Self: """Enter the context manager and return this client.""" return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: TracebackType | None, ) -> None: """Exit the context manager, closing the client.""" self.close() def __repr__(self) -> str: return f"Nyora(base_url={self.base_url!r})"
[docs] def health(self) -> dict[str, Any]: """Return the helper's ``/health`` payload. Returns: The health dict, or an empty dict if the response was not an object. """ data = self.get("/health") return data if isinstance(data, dict) else {}
[docs] def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: """Issue a ``GET`` request against the helper. Args: path: Request path relative to the base URL. params: Optional query parameters. Returns: Parsed JSON, or the response text for non-JSON bodies. Raises: NyoraHTTPError: If the helper returns a 4xx/5xx response. NyoraTransportError: If the engine is unreachable after retries. """ return self._request("GET", path, params=params)
[docs] def post( self, path: str, *, params: dict[str, Any] | None = None, json: Any | None = None, content: str | bytes | None = None, ) -> Any: """Issue a ``POST`` request against the helper. Args: path: Request path relative to the base URL. params: Optional query parameters. json: Optional JSON-serializable request body. content: Optional raw request body (mutually exclusive with ``json``). Returns: Parsed JSON, or the response text for non-JSON bodies. Raises: NyoraHTTPError: If the helper returns a 4xx/5xx response. NyoraTransportError: If the engine is unreachable after retries. """ return self._request("POST", path, params=params, json=json, content=content)
[docs] def delete(self, path: str, *, params: dict[str, Any] | None = None) -> Any: """Issue a ``DELETE`` request against the helper. Args: path: Request path relative to the base URL. params: Optional query parameters. Returns: Parsed JSON, or the response text for non-JSON bodies. Raises: NyoraHTTPError: If the helper returns a 4xx/5xx response. NyoraTransportError: If the engine is unreachable after retries. """ return self._request("DELETE", path, params=params)
def _request( self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any | None = None, content: str | bytes | None = None, ) -> Any: """Send a request with retries and decode the helper response. Retries transient transport errors and retryable status codes with exponential backoff (honouring ``Retry-After``) up to the configured limit, then raises. Raises: NyoraHTTPError: On a non-retryable (or final) 4xx/5xx response. NyoraTransportError: If the engine stays unreachable after retries. """ retry = self._retry attempt = 0 while True: try: response = self._http.request( method, path, params=params, json=json, content=content ) except TRANSIENT_EXCEPTIONS as exc: if attempt < retry.max_retries: delay = retry.backoff(attempt) logger.debug( "%s %s transport error %s; retry %d/%d in %.2fs", method, path, type(exc).__name__, attempt + 1, retry.max_retries, delay, ) time.sleep(delay) attempt += 1 continue raise _wrap_transport(exc) from exc if retry.should_retry_status(response.status_code) and attempt < retry.max_retries: delay = retry.backoff(attempt, retry_after=retry_after_seconds(response)) logger.debug( "%s %s -> HTTP %d; retry %d/%d in %.2fs", method, path, response.status_code, attempt + 1, retry.max_retries, delay, ) time.sleep(delay) attempt += 1 continue if response.status_code >= 400: raise NyoraHTTPError( response.status_code, _error_message(response), body=response.text ) return _decode(response)
[docs] class AsyncNyora: """Asynchronous Nyora client for the read/browse surface. Wraps an ``httpx.AsyncClient`` and exposes async ``sources`` and ``manga`` services (browse, search, details, pages) plus raw ``get``/``post``/``delete`` — with the same automatic retries and User-Agent as :class:`Nyora`. Use as an async context manager to release the connection on exit. Unlike :class:`Nyora`, this client does not launch a bundled engine; point it at a running/configured server. Attributes: base_url: The resolved helper base URL. sources: async source listing / lookup. manga: async browse, search, details, pages (with ``iter_*`` pagers). Example: >>> async with AsyncNyora.attach() as client: ... src = await client.sources.find("mangadex") ... async for manga in client.manga.iter_popular(src.id, limit=30): ... print(manga.title) """ def __init__( self, base_url: str | None = None, *, timeout: float = 60.0, retries: RetryConfig | int | None = None, ) -> None: """Connect to a helper. Args: base_url: Explicit helper base URL, or ``None`` to auto-discover. timeout: Per-request HTTP timeout in seconds. retries: Retry policy (int or :class:`~nyora.retry.RetryConfig`). Raises: HelperNotFoundError: If no helper can be discovered. """ resolved = _resolve_base_url(base_url) if resolved is None: raise HelperNotFoundError( "AsyncNyora needs a running or configured server — it does not launch a " "bundled engine. Set base_url, NYORA_BASE_URL, or `nyora config set-url`, " "or use the sync Nyora(), which can launch a bundled engine itself." ) self.base_url: str = resolved self._retry = RetryConfig.coerce(retries) self._http = httpx.AsyncClient( base_url=self.base_url, timeout=timeout, headers=_DEFAULT_HEADERS ) self.sources = _AsyncSourcesService(self) self.manga = _AsyncMangaService(self)
[docs] @classmethod def attach( cls, base_url: str | None = None, *, timeout: float = 60.0, retries: RetryConfig | int | None = None, ) -> AsyncNyora: """Attach to an already-running helper. Args: base_url: Explicit helper base URL, or ``None`` to auto-discover. timeout: Per-request HTTP timeout in seconds. retries: Retry policy (int or :class:`~nyora.retry.RetryConfig`). Returns: A connected async client. """ return cls(base_url=base_url, timeout=timeout, retries=retries)
[docs] async def close(self) -> None: """Close the underlying async HTTP connection.""" await self._http.aclose()
async def __aenter__(self) -> AsyncNyora: """Enter the async context manager and return this client.""" return self async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: TracebackType | None, ) -> None: """Exit the async context manager, closing the client.""" await self.close() def __repr__(self) -> str: return f"AsyncNyora(base_url={self.base_url!r})"
[docs] async def health(self) -> dict[str, Any]: """Return the helper's ``/health`` payload.""" data = await self.get("/health") return data if isinstance(data, dict) else {}
[docs] async def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: """Issue a ``GET`` request against the helper (with retries).""" return await self._request("GET", path, params=params)
[docs] async def post( self, path: str, *, params: dict[str, Any] | None = None, json: Any | None = None, content: str | bytes | None = None, ) -> Any: """Issue a ``POST`` request against the helper (with retries).""" return await self._request("POST", path, params=params, json=json, content=content)
[docs] async def delete(self, path: str, *, params: dict[str, Any] | None = None) -> Any: """Issue a ``DELETE`` request against the helper (with retries).""" return await self._request("DELETE", path, params=params)
async def _request( self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any | None = None, content: str | bytes | None = None, ) -> Any: """Async request with retries and response decoding (mirrors ``Nyora``).""" retry = self._retry attempt = 0 while True: try: response = await self._http.request( method, path, params=params, json=json, content=content ) except TRANSIENT_EXCEPTIONS as exc: if attempt < retry.max_retries: delay = retry.backoff(attempt) logger.debug( "%s %s transport error %s; retry %d/%d in %.2fs", method, path, type(exc).__name__, attempt + 1, retry.max_retries, delay, ) await asyncio.sleep(delay) attempt += 1 continue raise _wrap_transport(exc) from exc if retry.should_retry_status(response.status_code) and attempt < retry.max_retries: delay = retry.backoff(attempt, retry_after=retry_after_seconds(response)) logger.debug( "%s %s -> HTTP %d; retry %d/%d in %.2fs", method, path, response.status_code, attempt + 1, retry.max_retries, delay, ) await asyncio.sleep(delay) attempt += 1 continue if response.status_code >= 400: raise NyoraHTTPError( response.status_code, _error_message(response), body=response.text ) return _decode(response)
class _AsyncSourcesService: """Async source listing / lookup (``AsyncNyora.sources``).""" def __init__(self, client: AsyncNyora) -> None: self._client = client async def list(self) -> list[Source]: """List installed sources.""" data = await self._client.get("/sources") entries: list = [] if isinstance(data, dict): got = data.get("sources", data.get("entries", [])) entries = got if isinstance(got, list) else [] return [Source.from_json(item) for item in entries] async def find(self, query: str) -> Source: """Find an installed source by case-insensitive id or name substring.""" needle = query.casefold() for source in await self.list(): if needle in source.id.casefold() or needle in source.name.casefold(): return source raise LookupError(f"No installed source matched {query!r}") class _AsyncMangaService: """Async browse / search / read surface (``AsyncNyora.manga``).""" def __init__(self, client: AsyncNyora) -> None: self._client = client async def popular(self, source_id: str, page: int = 1) -> SearchPage: """Fetch a page of popular manga from a source.""" return SearchPage.from_json( await self._client.get("/sources/popular", params={"id": source_id, "page": page}) ) async def latest(self, source_id: str, page: int = 1) -> SearchPage: """Fetch a page of the latest-updated manga from a source.""" return SearchPage.from_json( await self._client.get("/sources/latest", params={"id": source_id, "page": page}) ) async def search( self, source_id: str, query: str, page: int = 1, *, filters: list[dict[str, Any]] | None = None, ) -> SearchPage: """Search a source for manga matching a query.""" params: dict[str, Any] = {"id": source_id, "q": query, "page": page} if filters: params["filters"] = filters return SearchPage.from_json(await self._client.get("/sources/search", params=params)) async def details( self, source_id: str, manga_url: str, *, manga_id: str | None = None ) -> MangaDetails: """Fetch full metadata and chapters for one manga.""" params = {"id": source_id, "url": manga_url} if manga_id: params["mangaId"] = manga_id return MangaDetails.from_json(await self._client.get("/manga/details", params=params)) async def pages( self, source_id: str, chapter_url: str, *, branch: str | None = None ) -> list[MangaPage]: """Resolve the readable image pages of a chapter.""" params = {"id": source_id, "url": chapter_url} if branch: params["branch"] = branch data = await self._client.get("/manga/pages", params=params) if isinstance(data, list): entries: list = data elif isinstance(data, dict) and isinstance(data.get("pages"), list): entries = data["pages"] else: entries = [] return [MangaPage.from_json(item) for item in entries] def iter_popular( self, source_id: str, *, start_page: int = 1, max_pages: int | None = None, limit: int | None = None, ) -> AsyncMangaPager: """Auto-paging async iterator over popular manga.""" return AsyncMangaPager( lambda p: self.popular(source_id, p), start_page=start_page, max_pages=max_pages, limit=limit, ) def iter_latest( self, source_id: str, *, start_page: int = 1, max_pages: int | None = None, limit: int | None = None, ) -> AsyncMangaPager: """Auto-paging async iterator over the latest manga.""" return AsyncMangaPager( lambda p: self.latest(source_id, p), start_page=start_page, max_pages=max_pages, limit=limit, ) def iter_search( self, source_id: str, query: str, *, start_page: int = 1, max_pages: int | None = None, limit: int | None = None, filters: list[dict[str, Any]] | None = None, ) -> AsyncMangaPager: """Auto-paging async iterator over search results.""" return AsyncMangaPager( lambda p: self.search(source_id, query, p, filters=filters), start_page=start_page, max_pages=max_pages, limit=limit, ) def _error_message(response: httpx.Response) -> str: """Extract a human-readable error message from a helper response. Args: response: The failed HTTP response. Returns: The ``error``/``message`` field of a JSON body, the trimmed text body, or the HTTP reason phrase as a fallback. """ try: data = response.json() except ValueError: return response.text.strip() or response.reason_phrase if isinstance(data, dict): return str(data.get("error") or data.get("message") or data) return str(data) __all__ = ["Nyora", "AsyncNyora"]