ranobelib-python-sdk 0.11.0__tar.gz → 0.12.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 (23) hide show
  1. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/__init__.py +2 -0
  5. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/catalog.py +70 -10
  6. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/client.py +31 -4
  7. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/models.py +8 -2
  8. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/sdk.py +1 -0
  9. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/README.md +0 -0
  10. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/_http.py +0 -0
  11. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/cache.py +0 -0
  12. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/console.py +0 -0
  13. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exceptions.py +0 -0
  14. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/__init__.py +0 -0
  15. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  16. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_shared.py +0 -0
  17. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/epub.py +0 -0
  18. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/fb2.py +0 -0
  19. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/pdf.py +0 -0
  20. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/txt.py +0 -0
  21. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/numbering.py +0 -0
  22. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/py.typed +0 -0
  23. {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.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.11.0
3
+ Version: 0.12.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.11.0"
3
+ version = "0.12.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.11.0"
3
+ version = "0.12.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"
@@ -21,6 +21,7 @@ from ranobelib.models import (
21
21
  Country,
22
22
  Footnote,
23
23
  Genre,
24
+ Label,
24
25
  Title,
25
26
  Volume,
26
27
  )
@@ -40,6 +41,7 @@ __all__ = [
40
41
  "DownloadTitleInterruptedError",
41
42
  "Footnote",
42
43
  "Genre",
44
+ "Label",
43
45
  "MultipleTitleTranslationsError",
44
46
  "MultipleTranslationsError",
45
47
  "RanobeLib",
@@ -14,7 +14,7 @@ from typing import Any, Self
14
14
 
15
15
  from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
16
16
  from ranobelib.client import RANOBELIB_SITE_ID, ApiClient
17
- from ranobelib.models import CatalogPage, Country, Genre, Title
17
+ from ranobelib.models import CatalogPage, Country, Genre, Label, Title
18
18
 
19
19
  DEFAULT_SORT = "last_chapter_at"
20
20
  """Default sort order: title's last-chapter timestamp — the closest real equivalent to
@@ -30,7 +30,7 @@ class Catalog:
30
30
  Example:
31
31
  ```python
32
32
  async with Catalog() as catalog:
33
- page = await catalog.list_titles(query="dxd", genres=[34], status=1)
33
+ page = await catalog.list_titles(query="dxd", genres=[34], statuses=[1])
34
34
  for title in page.items:
35
35
  print(title.name)
36
36
  ```
@@ -84,8 +84,10 @@ class Catalog:
84
84
  query: str | None = None,
85
85
  genres: list[int] | None = None,
86
86
  tags: list[int] | None = None,
87
- status: int | None = None,
87
+ statuses: list[int] | None = None,
88
88
  countries: list[int] | None = None,
89
+ min_chapters: int | None = None,
90
+ max_chapters: int | None = None,
89
91
  sort: str = DEFAULT_SORT,
90
92
  refresh: bool = False,
91
93
  ) -> CatalogPage:
@@ -107,7 +109,10 @@ class Catalog:
107
109
  against the live API, see docs/api-notes.md; not the OR some might expect
108
110
  from tags being more numerous/specific than genres). Also not validated
109
111
  before sending, same as ``genres``.
110
- status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
112
+ statuses: ``Title.status.id``s to filter by (e.g. ongoing, completed). A title
113
+ matches if its own status is *any* of these (OR, same as ``countries`` — a
114
+ title only has one status, see docs/api-notes.md). ``None`` or an empty list
115
+ applies no status filter. Ids come from ``list_statuses()``.
111
116
  countries: ``Country.id``s to filter by. A title matches if its own country is
112
117
  *any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
113
118
  country of origin, so requiring all of them could never match past the first,
@@ -116,6 +121,11 @@ class Catalog:
116
121
  parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
117
122
  why (``Country`` mirrors the API's own "type" concept, which isn't strictly
118
123
  limited to literal countries).
124
+ min_chapters: Only titles with at least this many chapters (inclusive). Counts
125
+ the same number as ``Title.chapter_count`` (uploaded chapters). ``None``
126
+ (the default) applies no lower bound.
127
+ max_chapters: Only titles with at most this many chapters (inclusive). ``None``
128
+ (the default) applies no upper bound.
119
129
  sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
120
130
  parameter — an actual ``sort`` parameter exists but is silently ignored by
121
131
  the API (see docs/api-notes.md). Known accepted values: ``"name"``,
@@ -130,8 +140,9 @@ class Catalog:
130
140
  page exists.
131
141
 
132
142
  Raises:
133
- ValueError: If ``page`` is less than 1, or ``per_page`` is outside
134
- ``MIN_PER_PAGE..MAX_PER_PAGE``.
143
+ ValueError: If ``page`` is less than 1, ``per_page`` is outside
144
+ ``MIN_PER_PAGE..MAX_PER_PAGE``, ``min_chapters``/``max_chapters`` is
145
+ negative, or ``min_chapters`` is greater than ``max_chapters``.
135
146
  """
136
147
  if page < 1:
137
148
  raise ValueError(f"page must be at least 1, got {page}.")
@@ -139,6 +150,16 @@ class Catalog:
139
150
  raise ValueError(
140
151
  f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
141
152
  )
153
+ for name, value in (("min_chapters", min_chapters), ("max_chapters", max_chapters)):
154
+ if value is not None and value < 0:
155
+ raise ValueError(f"{name} must be at least 0, got {value}.")
156
+ if min_chapters is not None and max_chapters is not None and min_chapters > max_chapters:
157
+ # The API itself silently swaps the bounds instead (see docs/api-notes.md) —
158
+ # raising here rather than relying on that undocumented behavior.
159
+ raise ValueError(
160
+ f"min_chapters ({min_chapters}) must not be greater than "
161
+ f"max_chapters ({max_chapters})."
162
+ )
142
163
 
143
164
  key = _cache_key(
144
165
  page=page,
@@ -146,8 +167,10 @@ class Catalog:
146
167
  query=query,
147
168
  genres=genres,
148
169
  tags=tags,
149
- status=status,
170
+ statuses=statuses,
150
171
  countries=countries,
172
+ min_chapters=min_chapters,
173
+ max_chapters=max_chapters,
151
174
  sort=sort,
152
175
  )
153
176
  if not refresh:
@@ -161,8 +184,10 @@ class Catalog:
161
184
  query=query,
162
185
  genres=genres,
163
186
  tags=tags,
164
- status=status,
187
+ statuses=statuses,
165
188
  countries=countries,
189
+ min_chapters=min_chapters,
190
+ max_chapters=max_chapters,
166
191
  sort=sort,
167
192
  )
168
193
  self._cache.set(key, data)
@@ -223,6 +248,33 @@ class Catalog:
223
248
  self._cache.set(key, data)
224
249
  return _build_countries(data)
225
250
 
251
+ async def list_statuses(self, *, refresh: bool = False) -> list[Label]:
252
+ """Fetch the full list of title statuses (id → label), for use as filter options
253
+ with ``list_titles(statuses=...)``.
254
+
255
+ Mirrors ``list_genres()``/``list_countries()``: the underlying endpoint
256
+ (``GET /api/constants?fields[]=status``) isn't site-scoped, so entries not tagged for
257
+ ranobelib.me are dropped before returning (see docs/api-notes.md). Returns the same
258
+ ``Label`` model as ``Title.status``, so a title's status can be compared against
259
+ these directly.
260
+
261
+ Args:
262
+ refresh: Bypass the disk cache and re-fetch from the API even if the status list
263
+ was already cached.
264
+
265
+ Returns:
266
+ Every status (``Label``: id, label) that applies to ranobelib.me.
267
+ """
268
+ key = "catalog:statuses"
269
+ if not refresh:
270
+ cached = self._cache.get(key)
271
+ if cached is not None:
272
+ return _build_statuses(cached)
273
+
274
+ data = await self._client.list_statuses()
275
+ self._cache.set(key, data)
276
+ return _build_statuses(data)
277
+
226
278
 
227
279
  def _cache_key(
228
280
  *,
@@ -231,16 +283,19 @@ def _cache_key(
231
283
  query: str | None,
232
284
  genres: list[int] | None,
233
285
  tags: list[int] | None,
234
- status: int | None,
286
+ statuses: list[int] | None,
235
287
  countries: list[int] | None,
288
+ min_chapters: int | None,
289
+ max_chapters: int | None,
236
290
  sort: str,
237
291
  ) -> str:
238
292
  genres_part = ",".join(str(genre_id) for genre_id in genres or [])
239
293
  tags_part = ",".join(str(tag_id) for tag_id in tags or [])
294
+ statuses_part = ",".join(str(status_id) for status_id in statuses or [])
240
295
  countries_part = ",".join(str(country_id) for country_id in countries or [])
241
296
  return (
242
297
  f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
243
- f"{status}:{countries_part}:{sort}"
298
+ f"{statuses_part}:{countries_part}:{min_chapters}:{max_chapters}:{sort}"
244
299
  )
245
300
 
246
301
 
@@ -258,3 +313,8 @@ def _build_genres(data: list[dict[str, Any]]) -> list[Genre]:
258
313
  def _build_countries(data: list[dict[str, Any]]) -> list[Country]:
259
314
  site_id = int(RANOBELIB_SITE_ID)
260
315
  return [Country.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
316
+
317
+
318
+ def _build_statuses(data: list[dict[str, Any]]) -> list[Label]:
319
+ site_id = int(RANOBELIB_SITE_ID)
320
+ return [Label.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
@@ -189,8 +189,10 @@ class ApiClient:
189
189
  query: str | None,
190
190
  genres: list[int] | None,
191
191
  tags: list[int] | None,
192
- status: int | None,
192
+ statuses: list[int] | None,
193
193
  countries: list[int] | None,
194
+ min_chapters: int | None,
195
+ max_chapters: int | None,
194
196
  sort: str,
195
197
  ) -> dict[str, Any]:
196
198
  """Fetch one page of the catalog listing/search results.
@@ -209,12 +211,16 @@ class ApiClient:
209
211
  see docs/api-notes.md), not just any one.
210
212
  tags: Tag ids to filter by. Same AND semantics as ``genres`` — a title must have
211
213
  *all* of them, not just any one (see docs/api-notes.md).
212
- status: A single ``Title.status.id`` to filter by.
214
+ statuses: ``Title.status.id``s to filter by. A title matches if its own status
215
+ is *any* of these (OR, like ``countries`` — a title only has one status, see
216
+ docs/api-notes.md). Sent on the wire as repeated ``status[]`` parameters.
213
217
  countries: ``Country.id``s to filter by. A title matches if its own country is
214
218
  *any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
215
219
  country, confirmed against the live API, see docs/api-notes.md). Sent on the
216
220
  wire as repeated ``types[]`` parameters — the API's own name for this concept
217
221
  is "type", not "country" (see docs/api-notes.md).
222
+ min_chapters: Minimum chapter count, inclusive. Sent as ``chap_count_min``.
223
+ max_chapters: Maximum chapter count, inclusive. Sent as ``chap_count_max``.
218
224
  sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
219
225
  silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
220
226
  mismatch between the SDK's public keyword and the wire parameter name is
@@ -235,10 +241,14 @@ class ApiClient:
235
241
  params.append(("genres[]", str(genre_id)))
236
242
  for tag_id in tags or []:
237
243
  params.append(("tags[]", str(tag_id)))
238
- if status is not None:
239
- params.append(("status[]", str(status)))
244
+ for status_id in statuses or []:
245
+ params.append(("status[]", str(status_id)))
240
246
  for country_id in countries or []:
241
247
  params.append(("types[]", str(country_id)))
248
+ if min_chapters is not None:
249
+ params.append(("chap_count_min", str(min_chapters)))
250
+ if max_chapters is not None:
251
+ params.append(("chap_count_max", str(max_chapters)))
242
252
 
243
253
  response = await self._get("/manga", params=httpx.QueryParams(params))
244
254
  self._raise_for_status(
@@ -283,6 +293,23 @@ class ApiClient:
283
293
  data: list[dict[str, Any]] = response.json()["data"]["types"]
284
294
  return data
285
295
 
296
+ async def list_statuses(self) -> list[dict[str, Any]]:
297
+ """Fetch the raw title status list shared across the whole lib.social network.
298
+
299
+ Same shape and caveats as ``list_genres``: not site-scoped by any request parameter,
300
+ returns every status known to the network at once, each tagged with the site ids it
301
+ applies to via ``site_ids``.
302
+
303
+ Returns:
304
+ The raw ``data.status`` array from the API response, unfiltered by site.
305
+ """
306
+ response = await self._get("/constants", params=httpx.QueryParams([("fields[]", "status")]))
307
+ self._raise_for_status(
308
+ response, not_found=RanobeLibError("Unexpected 404 from status list")
309
+ )
310
+ data: list[dict[str, Any]] = response.json()["data"]["status"]
311
+ return data
312
+
286
313
  async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
287
314
  """Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
288
315
  attempt = 0
@@ -463,6 +463,11 @@ class Title(BaseModel):
463
463
  artists: list[Person] = Field(default_factory=list)
464
464
  teams: list[Team] = Field(default_factory=list)
465
465
  chapter_count: int | None = None
466
+ last_chapter_at: datetime | None = Field(default=None, alias="last_item_at")
467
+ """When the most recent chapter was published — the value the catalog's
468
+ ``sort="last_chapter_at"`` orders by. Only ``RanobeLib.get_info()`` fills it in: the
469
+ catalog listing endpoint doesn't return it (see docs/api-notes.md), so it's ``None`` on
470
+ ``Catalog.list_titles()`` items, and on a title with no chapters yet."""
466
471
 
467
472
  @model_validator(mode="before")
468
473
  @classmethod
@@ -581,8 +586,9 @@ class CatalogPage(BaseModel):
581
586
  """One page of catalog listing/search results, as returned by ``Catalog.list_titles()``.
582
587
 
583
588
  ``items`` reuses ``Title`` as-is — a catalog list item has every field ``Title`` requires,
584
- the ones it doesn't send (``genres``, ``summary``, ``chapter_count``, ...) just come back
585
- at their defaults, same as any other partially-populated ``Title`` (see docs/api-notes.md).
589
+ the ones it doesn't send (``genres``, ``summary``, ``chapter_count``, ``last_chapter_at``,
590
+ ...) just come back at their defaults, same as any other partially-populated ``Title``
591
+ (see docs/api-notes.md).
586
592
  """
587
593
 
588
594
  items: list[Title]
@@ -48,6 +48,7 @@ _INFO_FIELDS = [
48
48
  "authors",
49
49
  "artists",
50
50
  "chap_count",
51
+ "last_item_at",
51
52
  ]
52
53
 
53
54