ranobelib-python-sdk 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/__init__.py +4 -1
  5. ranobelib_python_sdk-0.6.0/src/ranobelib/catalog.py +156 -0
  6. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/client.py +54 -0
  7. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/models.py +13 -0
  8. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/README.md +0 -0
  9. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/cache.py +0 -0
  10. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/console.py +0 -0
  11. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exceptions.py +0 -0
  12. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/__init__.py +0 -0
  13. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  14. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/_shared.py +0 -0
  15. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/epub.py +0 -0
  16. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/fb2.py +0 -0
  17. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/pdf.py +0 -0
  18. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/txt.py +0 -0
  19. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/numbering.py +0 -0
  20. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/py.typed +0 -0
  21. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/sdk.py +0 -0
  22. {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/sizing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ranobelib-python-sdk
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf
5
5
  Keywords: ranobelib,ranobe,novel,webnovel,epub,fb2,sdk
6
6
  Author: LLIJIYAPNIK
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,5 +1,6 @@
1
1
  """Async Python SDK for ranobelib.me."""
2
2
 
3
+ from ranobelib.catalog import Catalog
3
4
  from ranobelib.exceptions import (
4
5
  AmbiguousChapter,
5
6
  AuthRequiredError,
@@ -11,13 +12,15 @@ from ranobelib.exceptions import (
11
12
  TitleNotFoundError,
12
13
  VolumeNotFoundError,
13
14
  )
14
- from ranobelib.models import Chapter, ChapterBranch, Title, Volume
15
+ from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Title, Volume
15
16
  from ranobelib.sdk import RanobeLib
16
17
  from ranobelib.sizing import chapter_size, volume_size
17
18
 
18
19
  __all__ = [
19
20
  "AmbiguousChapter",
20
21
  "AuthRequiredError",
22
+ "Catalog",
23
+ "CatalogPage",
21
24
  "Chapter",
22
25
  "ChapterBranch",
23
26
  "ChapterNotFoundError",
@@ -0,0 +1,156 @@
1
+ """Catalog listing/search: the ``Catalog`` class.
2
+
3
+ Unlike ``RanobeLib`` (scoped to one title via its URL/slug), catalog listing isn't scoped to
4
+ any title at all — see CLAUDE.md's roadmap entry for why this is a separate entry point with
5
+ its own ``ApiClient``/``DiskCache`` rather than a method on ``RanobeLib``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from types import TracebackType
12
+ from typing import Any, Self
13
+
14
+ from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
15
+ from ranobelib.client import ApiClient
16
+ from ranobelib.models import CatalogPage, Title
17
+
18
+ DEFAULT_SORT = "last_chapter_at"
19
+ """Default sort order: title's last-chapter timestamp — the closest real equivalent to
20
+ "recently updated" (the API has no ``updated_at`` sort value, see docs/api-notes.md)."""
21
+
22
+ MIN_PER_PAGE = 10
23
+ MAX_PER_PAGE = 60
24
+
25
+
26
+ class Catalog:
27
+ """Entry point for listing/searching the ranobelib.me catalog.
28
+
29
+ Example:
30
+ ```python
31
+ async with Catalog() as catalog:
32
+ page = await catalog.list_titles(query="dxd", genres=[34], status=1)
33
+ for title in page.items:
34
+ print(title.name)
35
+ ```
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ cache_dir: str | Path | None = None,
42
+ cache_ttl: float | None = None,
43
+ ) -> None:
44
+ """Initialize the catalog client.
45
+
46
+ Args:
47
+ cache_dir: Where to cache raw API responses on disk, one entry per distinct set
48
+ of listing parameters (page/filters/sort) — same disk cache as ``RanobeLib``
49
+ uses (``cache.py``), just keyed by listing parameters instead of a title's
50
+ slug. Defaults to ``.ranobelib_cache`` in the current working directory.
51
+ cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
52
+ ``None`` (the default) means cached pages never expire on their own — see
53
+ ``refresh=True`` on ``list_titles()`` to force one anyway.
54
+ """
55
+ self._client = ApiClient()
56
+ self._cache = DiskCache(
57
+ cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
58
+ )
59
+
60
+ async def __aenter__(self) -> Self:
61
+ return self
62
+
63
+ async def __aexit__(
64
+ self,
65
+ exc_type: type[BaseException] | None,
66
+ exc: BaseException | None,
67
+ traceback: TracebackType | None,
68
+ ) -> None:
69
+ await self.aclose()
70
+
71
+ async def aclose(self) -> None:
72
+ """Close the underlying HTTP client."""
73
+ await self._client.aclose()
74
+
75
+ async def list_titles(
76
+ self,
77
+ *,
78
+ page: int = 1,
79
+ per_page: int = 30,
80
+ query: str | None = None,
81
+ genres: list[int] | None = None,
82
+ status: int | None = None,
83
+ sort: str = DEFAULT_SORT,
84
+ refresh: bool = False,
85
+ ) -> CatalogPage:
86
+ """Fetch one page of catalog listing/search results.
87
+
88
+ Args:
89
+ page: 1-based page number.
90
+ per_page: How many titles per page. Must be between ``MIN_PER_PAGE`` (10) and
91
+ ``MAX_PER_PAGE`` (60) — the API's own limits (see docs/api-notes.md);
92
+ anything outside that range raises here instead of a confusing 422 from
93
+ the API.
94
+ query: Free-text search term, matched against name/rus_name/eng_name. ``None``
95
+ (the default) or an empty string lists without searching.
96
+ genres: Genre ids to filter by. A title must have *all* of them, not just one
97
+ (AND, not OR — see docs/api-notes.md). The SDK doesn't validate these against
98
+ a known genre list — there's no public, unauthenticated way to fetch one (see
99
+ docs/api-notes.md) — an unrecognized id just matches nothing.
100
+ status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
101
+ sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
102
+ parameter — an actual ``sort`` parameter exists but is silently ignored by
103
+ the API (see docs/api-notes.md). Known accepted values: ``"name"``,
104
+ ``"created_at"``, ``"views"``, ``"chap_count"``, ``"last_chapter_at"``
105
+ (the default), ``"rate_avg"``, ``"random"`` — this list isn't guaranteed
106
+ exhaustive.
107
+ refresh: Bypass the disk cache and re-fetch from the API even if this exact
108
+ combination of parameters was already cached.
109
+
110
+ Returns:
111
+ A ``CatalogPage``: the matching titles (as ``Title`` models) plus whether a next
112
+ page exists.
113
+
114
+ Raises:
115
+ ValueError: If ``page`` is less than 1, or ``per_page`` is outside
116
+ ``MIN_PER_PAGE..MAX_PER_PAGE``.
117
+ """
118
+ if page < 1:
119
+ raise ValueError(f"page must be at least 1, got {page}.")
120
+ if not MIN_PER_PAGE <= per_page <= MAX_PER_PAGE:
121
+ raise ValueError(
122
+ f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
123
+ )
124
+
125
+ key = _cache_key(
126
+ page=page, per_page=per_page, query=query, genres=genres, status=status, sort=sort
127
+ )
128
+ if not refresh:
129
+ cached = self._cache.get(key)
130
+ if cached is not None:
131
+ return _build_page(cached)
132
+
133
+ data = await self._client.list_titles(
134
+ page=page, per_page=per_page, query=query, genres=genres, status=status, sort=sort
135
+ )
136
+ self._cache.set(key, data)
137
+ return _build_page(data)
138
+
139
+
140
+ def _cache_key(
141
+ *,
142
+ page: int,
143
+ per_page: int,
144
+ query: str | None,
145
+ genres: list[int] | None,
146
+ status: int | None,
147
+ sort: str,
148
+ ) -> str:
149
+ genres_part = ",".join(str(genre_id) for genre_id in genres or [])
150
+ return f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{status}:{sort}"
151
+
152
+
153
+ def _build_page(data: dict[str, Any]) -> CatalogPage:
154
+ items = [Title.model_validate(item) for item in data["data"]]
155
+ meta = data["meta"]
156
+ return CatalogPage(items=items, page=meta["current_page"], has_next_page=meta["has_next_page"])
@@ -163,6 +163,60 @@ class ApiClient:
163
163
  data: dict[str, Any] = response.json()["data"]
164
164
  return data
165
165
 
166
+ async def list_titles(
167
+ self,
168
+ *,
169
+ page: int,
170
+ per_page: int,
171
+ query: str | None,
172
+ genres: list[int] | None,
173
+ status: int | None,
174
+ sort: str,
175
+ ) -> dict[str, Any]:
176
+ """Fetch one page of the catalog listing/search results.
177
+
178
+ Unlike the other ``ApiClient`` methods, this returns the *whole* response body, not
179
+ just ``data`` — callers need ``meta.has_next_page`` too, and there's no total count
180
+ to derive it from otherwise (see docs/api-notes.md).
181
+
182
+ Args:
183
+ page: 1-based page number.
184
+ per_page: Page size. The API only accepts ``10..60`` (validated by the caller,
185
+ see ``Catalog.list_titles``); values outside that raise here via a 422.
186
+ query: Free-text search term, matched against name/rus_name/eng_name. ``None``
187
+ or empty omits the ``q`` parameter entirely.
188
+ genres: Genre ids to filter by. A title must have *all* of them (AND, not OR —
189
+ see docs/api-notes.md), not just any one.
190
+ status: A single ``Title.status.id`` to filter by.
191
+ sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
192
+ silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
193
+ mismatch between the SDK's public keyword and the wire parameter name is
194
+ deliberate, not a typo.
195
+
196
+ Returns:
197
+ The full JSON response: ``{"data": [...], "meta": {...}, "links": {...}}``.
198
+ """
199
+ params: list[tuple[str, str | int | float | bool | None]] = [
200
+ ("site_id[]", RANOBELIB_SITE_ID),
201
+ ("page", str(page)),
202
+ ("limit", str(per_page)),
203
+ ("sort_by", sort),
204
+ ]
205
+ if query:
206
+ params.append(("q", query))
207
+ for genre_id in genres or []:
208
+ params.append(("genres[]", str(genre_id)))
209
+ if status is not None:
210
+ params.append(("status[]", str(status)))
211
+
212
+ response = await self._get("/manga", params=httpx.QueryParams(params))
213
+ self._raise_for_status(
214
+ response,
215
+ not_found=RanobeLibError(f"Unexpected 404 from catalog listing (page={page})"),
216
+ )
217
+ result: dict[str, Any] = response.json()
218
+ return result
219
+
166
220
  async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
167
221
  """Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
168
222
  attempt = 0
@@ -329,3 +329,16 @@ class Volume(BaseModel):
329
329
 
330
330
  number: str
331
331
  chapters: list[Chapter] = Field(default_factory=list)
332
+
333
+
334
+ class CatalogPage(BaseModel):
335
+ """One page of catalog listing/search results, as returned by ``Catalog.list_titles()``.
336
+
337
+ ``items`` reuses ``Title`` as-is — a catalog list item has every field ``Title`` requires,
338
+ the ones it doesn't send (``genres``, ``summary``, ``chapter_count``, ...) just come back
339
+ at their defaults, same as any other partially-populated ``Title`` (see docs/api-notes.md).
340
+ """
341
+
342
+ items: list[Title]
343
+ page: int
344
+ has_next_page: bool