ranobelib-python-sdk 0.6.0__tar.gz → 0.7.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.6.0 → ranobelib_python_sdk-0.7.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/__init__.py +4 -1
  5. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/catalog.py +37 -5
  6. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/client.py +18 -0
  7. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/exceptions.py +34 -1
  8. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/sdk.py +93 -9
  9. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/README.md +0 -0
  10. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/cache.py +0 -0
  11. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/console.py +0 -0
  12. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/exporters/__init__.py +0 -0
  13. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  14. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/exporters/_shared.py +0 -0
  15. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/exporters/epub.py +0 -0
  16. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/exporters/fb2.py +0 -0
  17. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/exporters/pdf.py +0 -0
  18. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/exporters/txt.py +0 -0
  19. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/models.py +0 -0
  20. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/numbering.py +0 -0
  21. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.0}/src/ranobelib/py.typed +0 -0
  22. {ranobelib_python_sdk-0.6.0 → ranobelib_python_sdk-0.7.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.6.0
3
+ Version: 0.7.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.6.0"
3
+ version = "0.7.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.6.0"
3
+ version = "0.7.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"
@@ -5,6 +5,7 @@ from ranobelib.exceptions import (
5
5
  AmbiguousChapter,
6
6
  AuthRequiredError,
7
7
  ChapterNotFoundError,
8
+ DownloadTitleInterruptedError,
8
9
  MultipleTitleTranslationsError,
9
10
  MultipleTranslationsError,
10
11
  RanobeLibError,
@@ -12,7 +13,7 @@ from ranobelib.exceptions import (
12
13
  TitleNotFoundError,
13
14
  VolumeNotFoundError,
14
15
  )
15
- from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Title, Volume
16
+ from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Genre, Title, Volume
16
17
  from ranobelib.sdk import RanobeLib
17
18
  from ranobelib.sizing import chapter_size, volume_size
18
19
 
@@ -24,6 +25,8 @@ __all__ = [
24
25
  "Chapter",
25
26
  "ChapterBranch",
26
27
  "ChapterNotFoundError",
28
+ "DownloadTitleInterruptedError",
29
+ "Genre",
27
30
  "MultipleTitleTranslationsError",
28
31
  "MultipleTranslationsError",
29
32
  "RanobeLib",
@@ -12,8 +12,8 @@ from types import TracebackType
12
12
  from typing import Any, Self
13
13
 
14
14
  from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
15
- from ranobelib.client import ApiClient
16
- from ranobelib.models import CatalogPage, Title
15
+ from ranobelib.client import RANOBELIB_SITE_ID, ApiClient
16
+ from ranobelib.models import CatalogPage, Genre, Title
17
17
 
18
18
  DEFAULT_SORT = "last_chapter_at"
19
19
  """Default sort order: title's last-chapter timestamp — the closest real equivalent to
@@ -94,9 +94,8 @@ class Catalog:
94
94
  query: Free-text search term, matched against name/rus_name/eng_name. ``None``
95
95
  (the default) or an empty string lists without searching.
96
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.
97
+ (AND, not OR — see docs/api-notes.md). Not validated against ``list_genres()``
98
+ before sending — an unrecognized id just matches nothing rather than erroring.
100
99
  status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
101
100
  sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
102
101
  parameter — an actual ``sort`` parameter exists but is silently ignored by
@@ -136,6 +135,34 @@ class Catalog:
136
135
  self._cache.set(key, data)
137
136
  return _build_page(data)
138
137
 
138
+ async def list_genres(self, *, refresh: bool = False) -> list[Genre]:
139
+ """Fetch the full list of catalog genres (id → name), for use as filter options
140
+ with ``list_titles(genres=[...])``.
141
+
142
+ The underlying endpoint (``GET /api/constants?fields[]=genres``) has no site-scoping
143
+ parameter — it returns the full genre list shared across the whole lib.social
144
+ network in one shot, each tagged with the site ids it applies to (see
145
+ docs/api-notes.md). This filters that down to genres tagged for ranobelib.me before
146
+ returning, so callers don't get filter options (e.g. "Детское") that could never
147
+ match any ranobelib title.
148
+
149
+ Args:
150
+ refresh: Bypass the disk cache and re-fetch from the API even if the genre list
151
+ was already cached.
152
+
153
+ Returns:
154
+ Every ``Genre`` (id, name, adult flag) that applies to ranobelib.me.
155
+ """
156
+ key = "catalog:genres"
157
+ if not refresh:
158
+ cached = self._cache.get(key)
159
+ if cached is not None:
160
+ return _build_genres(cached)
161
+
162
+ data = await self._client.list_genres()
163
+ self._cache.set(key, data)
164
+ return _build_genres(data)
165
+
139
166
 
140
167
  def _cache_key(
141
168
  *,
@@ -154,3 +181,8 @@ def _build_page(data: dict[str, Any]) -> CatalogPage:
154
181
  items = [Title.model_validate(item) for item in data["data"]]
155
182
  meta = data["meta"]
156
183
  return CatalogPage(items=items, page=meta["current_page"], has_next_page=meta["has_next_page"])
184
+
185
+
186
+ def _build_genres(data: list[dict[str, Any]]) -> list[Genre]:
187
+ site_id = int(RANOBELIB_SITE_ID)
188
+ return [Genre.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
@@ -217,6 +217,24 @@ class ApiClient:
217
217
  result: dict[str, Any] = response.json()
218
218
  return result
219
219
 
220
+ async def list_genres(self) -> list[dict[str, Any]]:
221
+ """Fetch the raw genre list shared across the whole lib.social network.
222
+
223
+ Unlike ``list_titles``, this endpoint isn't site-scoped by any request parameter
224
+ (confirmed: a ``Site-Id`` header changes nothing about the response) — it returns
225
+ every genre known to the network at once, each tagged with the site ids it applies
226
+ to via a ``site_ids`` field. Callers must filter to ``RANOBELIB_SITE_ID`` themselves
227
+ (see ``Catalog.list_genres``, which drops e.g. site-5-only genres like "Детское"
228
+ before returning).
229
+
230
+ Returns:
231
+ The raw ``data.genres`` array from the API response, unfiltered by site.
232
+ """
233
+ response = await self._get("/constants", params=httpx.QueryParams([("fields[]", "genres")]))
234
+ self._raise_for_status(response, not_found=RanobeLibError("Unexpected 404 from genre list"))
235
+ data: list[dict[str, Any]] = response.json()["data"]["genres"]
236
+ return data
237
+
220
238
  async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
221
239
  """Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
222
240
  attempt = 0
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING, NamedTuple
6
6
 
7
7
  if TYPE_CHECKING:
8
- from ranobelib.models import ChapterBranch
8
+ from ranobelib.models import ChapterBranch, Volume
9
9
 
10
10
 
11
11
  class RanobeLibError(Exception):
@@ -164,3 +164,36 @@ class RateLimitError(RanobeLibError):
164
164
  self.retry_after = retry_after
165
165
  suffix = f" (retry after {retry_after}s)" if retry_after is not None else ""
166
166
  super().__init__(f"Rate limited by the API{suffix}")
167
+
168
+
169
+ class DownloadTitleInterruptedError(RanobeLibError):
170
+ """Raised by ``download_title()`` when a chapter fetch fails unrecoverably partway through.
171
+
172
+ ``download_title()`` fetches hundreds-to-thousands of chapters sequentially for a long
173
+ title, which can outlast even the extra, bulk-specific rate-limit retry budget described
174
+ in ``download_title()``'s ``max_rate_limit_retries`` — or hit some other unrecoverable
175
+ error (e.g. ``AuthRequiredError`` on a chapter that turns out to need a paywall/auth
176
+ token). Rather than letting that error propagate on its own and silently discard every
177
+ chapter already fetched, it's wrapped in this exception instead, carrying what was
178
+ already downloaded — so a caller can keep that partial result, or simply call
179
+ ``download_title()`` again: the SDK's disk cache means already-fetched chapters aren't
180
+ re-requested, so a retry resumes close to where this one stopped rather than restarting
181
+ the whole title. See docs/api-notes.md's "Rate limiting и retry" section and issue #41.
182
+
183
+ Attributes:
184
+ slug_url: The title's ``{id}--{slug}`` identifier.
185
+ volumes: Chapters fetched before the failure, grouped into volumes the same way a
186
+ successful ``download_title()`` call would return them.
187
+ completed: How many chapters were fetched before the failure.
188
+ total: How many chapters ``download_title()`` had planned to fetch in total.
189
+ """
190
+
191
+ def __init__(self, slug_url: str, *, volumes: list[Volume], completed: int, total: int) -> None:
192
+ self.slug_url = slug_url
193
+ self.volumes = volumes
194
+ self.completed = completed
195
+ self.total = total
196
+ super().__init__(
197
+ f"download_title() for {slug_url!r} was interrupted after {completed}/{total} "
198
+ "chapter(s) fetched — see __cause__ for why, .volumes for what was already fetched."
199
+ )
@@ -14,8 +14,11 @@ from ranobelib.console import Reporter, Verbosity
14
14
  from ranobelib.exceptions import (
15
15
  AmbiguousChapter,
16
16
  ChapterNotFoundError,
17
+ DownloadTitleInterruptedError,
17
18
  MultipleTitleTranslationsError,
18
19
  MultipleTranslationsError,
20
+ RanobeLibError,
21
+ RateLimitError,
19
22
  VolumeNotFoundError,
20
23
  )
21
24
  from ranobelib.exporters import EXPORTERS
@@ -23,6 +26,16 @@ from ranobelib.models import Chapter, ChapterBranch, Title, Volume
23
26
  from ranobelib.numbering import parse_slug_url
24
27
  from ranobelib.sizing import DEFAULT_AVERAGE_IMAGE_SIZE, DEFAULT_SAMPLE_SIZE, chapter_size
25
28
 
29
+ DEFAULT_RATE_LIMIT_RETRIES = 6
30
+ """Default ``download_title(max_rate_limit_retries=...)`` — see docs/api-notes.md."""
31
+
32
+ _RATE_LIMIT_RETRY_BASE_DELAY = 5.0
33
+ """Base delay, in seconds, for download_title()'s bulk rate-limit backoff (doubled per
34
+ attempt, capped at _RATE_LIMIT_RETRY_MAX_DELAY) — used when the API's 429 doesn't send a
35
+ Retry-After header."""
36
+
37
+ _RATE_LIMIT_RETRY_MAX_DELAY = 60.0
38
+
26
39
  _INFO_FIELDS = [
27
40
  "background",
28
41
  "eng_name",
@@ -235,6 +248,7 @@ class RanobeLib:
235
248
  translation_index: int | None = None,
236
249
  chapter_delay: float = 0.0,
237
250
  on_chapter: Callable[[int, int], None] | None = None,
251
+ max_rate_limit_retries: int = DEFAULT_RATE_LIMIT_RETRIES,
238
252
  ) -> list[Volume]:
239
253
  """Download every chapter of the title, across all its volumes.
240
254
 
@@ -248,6 +262,14 @@ class RanobeLib:
248
262
  ``branch_id`` or ``translation_index`` resolves every one of them), rather than
249
263
  failing partway through a long download on the first ambiguous chapter.
250
264
 
265
+ A long title's sequential download can outlast ``ApiClient``'s own retry budget for
266
+ 429s (tuned for a single one-off request, not hundreds/thousands of sequential
267
+ fetches — see docs/api-notes.md). On top of that, each chapter fetch that still comes
268
+ back rate-limited is retried again here, up to ``max_rate_limit_retries`` times, with
269
+ its own (coarser) backoff. If a chapter fetch still fails after that — from rate
270
+ limiting or anything else — the chapters already downloaded are not discarded: they're
271
+ attached to a raised ``DownloadTitleInterruptedError`` instead of being lost.
272
+
251
273
  Args:
252
274
  branch_id: Translation to use for every chapter that has more than one — same
253
275
  meaning as ``get_chapter(..., branch_id=...)``, applied title-wide. Chapters
@@ -266,11 +288,20 @@ class RanobeLib:
266
288
  who would otherwise have no way to observe anything before this whole
267
289
  ``await`` returns. Independent of ``verbosity``: both can be set at once,
268
290
  each drives its own output.
291
+ max_rate_limit_retries: How many extra times to retry a single chapter fetch that
292
+ comes back rate-limited, on top of ``ApiClient``'s own retries, before giving
293
+ up on the whole download — see above. Each retry honors the API's
294
+ ``Retry-After`` if it sent one, otherwise waits with its own capped
295
+ exponential backoff. Set to ``0`` to disable this extra layer and let a
296
+ persistent ``RateLimitError`` end the download immediately (still wrapped in
297
+ ``DownloadTitleInterruptedError`` with whatever was already fetched).
269
298
 
270
299
  Raises:
271
300
  ValueError: If both ``branch_id`` and ``translation_index`` are given.
272
301
  MultipleTitleTranslationsError: If the title has one or more chapters with more
273
302
  than one translation that ``branch_id``/``translation_index`` didn't resolve.
303
+ DownloadTitleInterruptedError: If fetching a chapter fails unrecoverably partway
304
+ through the download — carries every chapter already fetched.
274
305
  """
275
306
  if branch_id is not None and translation_index is not None:
276
307
  raise ValueError("Pass at most one of branch_id or translation_index, not both.")
@@ -304,16 +335,31 @@ class RanobeLib:
304
335
  if unresolved:
305
336
  raise MultipleTitleTranslationsError(self._slug_url, chapters=unresolved)
306
337
 
307
- chapters = []
338
+ chapters: list[Chapter] = []
308
339
  total = len(planned)
309
- with self._reporter.progress(f"Downloading {self._slug_url}", total) as advance:
310
- for index, (volume_str, number, selected_branch_id) in enumerate(planned):
311
- chapters.append(await self._fetch_chapter(volume_str, number, selected_branch_id))
312
- advance()
313
- if on_chapter is not None:
314
- on_chapter(index + 1, total)
315
- if chapter_delay and index < total - 1:
316
- await asyncio.sleep(chapter_delay)
340
+ try:
341
+ with self._reporter.progress(f"Downloading {self._slug_url}", total) as advance:
342
+ for index, (volume_str, number, selected_branch_id) in enumerate(planned):
343
+ chapters.append(
344
+ await self._fetch_chapter_riding_out_rate_limits(
345
+ volume_str,
346
+ number,
347
+ selected_branch_id,
348
+ max_rate_limit_retries=max_rate_limit_retries,
349
+ )
350
+ )
351
+ advance()
352
+ if on_chapter is not None:
353
+ on_chapter(index + 1, total)
354
+ if chapter_delay and index < total - 1:
355
+ await asyncio.sleep(chapter_delay)
356
+ except RanobeLibError as exc:
357
+ raise DownloadTitleInterruptedError(
358
+ self._slug_url,
359
+ volumes=_group_into_volumes(chapters),
360
+ completed=len(chapters),
361
+ total=total,
362
+ ) from exc
317
363
  return _group_into_volumes(chapters)
318
364
 
319
365
  async def estimate_title_size(
@@ -476,6 +522,44 @@ class RanobeLib:
476
522
  self._cache.set(key, data)
477
523
  return Chapter.model_validate(data)
478
524
 
525
+ async def _fetch_chapter_riding_out_rate_limits(
526
+ self,
527
+ volume_str: str,
528
+ number: str,
529
+ branch_id: int | None,
530
+ *,
531
+ max_rate_limit_retries: int,
532
+ ) -> Chapter:
533
+ """Fetch one chapter for a bulk download, retrying past ``ApiClient``'s own budget.
534
+
535
+ ``ApiClient._get()`` already retries a 429 a few times with its own backoff, tuned
536
+ for a single request (see docs/api-notes.md) — not enough to ride out sustained rate
537
+ limiting partway through a long sequential download. This adds a second, coarser
538
+ retry layer on top: on a ``RateLimitError`` that already survived ``ApiClient``'s own
539
+ retries, wait (honoring ``Retry-After`` if given, otherwise capped exponential
540
+ backoff) and try again, up to ``max_rate_limit_retries`` times, before finally letting
541
+ the error propagate to the caller (``download_title()``, which turns it into
542
+ ``DownloadTitleInterruptedError`` carrying whatever was already downloaded).
543
+ """
544
+ attempt = 0
545
+ while True:
546
+ try:
547
+ return await self._fetch_chapter(volume_str, number, branch_id)
548
+ except RateLimitError as exc:
549
+ if attempt >= max_rate_limit_retries:
550
+ raise
551
+ delay = exc.retry_after
552
+ if delay is None:
553
+ delay = min(
554
+ _RATE_LIMIT_RETRY_BASE_DELAY * (2**attempt), _RATE_LIMIT_RETRY_MAX_DELAY
555
+ )
556
+ self._reporter.log(
557
+ f"[rate-limit] chapter {volume_str}/{number}: retrying in {delay:g}s "
558
+ f"(attempt {attempt + 1}/{max_rate_limit_retries})"
559
+ )
560
+ await asyncio.sleep(delay)
561
+ attempt += 1
562
+
479
563
  def _resolve_branch_id(
480
564
  self, volume_str: str, number: str, raw_chapters: list[dict[str, Any]]
481
565
  ) -> int | None: