ranobelib-python-sdk 0.7.0__tar.gz → 0.8.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.7.0 → ranobelib_python_sdk-0.8.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/__init__.py +2 -1
  5. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/catalog.py +68 -4
  6. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/client.py +28 -0
  7. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/models.py +20 -0
  8. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/README.md +0 -0
  9. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/cache.py +0 -0
  10. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/console.py +0 -0
  11. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exceptions.py +0 -0
  12. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/__init__.py +0 -0
  13. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  14. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/_shared.py +0 -0
  15. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/epub.py +0 -0
  16. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/fb2.py +0 -0
  17. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/pdf.py +0 -0
  18. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/txt.py +0 -0
  19. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/numbering.py +0 -0
  20. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/py.typed +0 -0
  21. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.0}/src/ranobelib/sdk.py +0 -0
  22. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.8.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.7.0
3
+ Version: 0.8.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.7.0"
3
+ version = "0.8.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.7.0"
3
+ version = "0.8.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"
@@ -13,7 +13,7 @@ from ranobelib.exceptions import (
13
13
  TitleNotFoundError,
14
14
  VolumeNotFoundError,
15
15
  )
16
- from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Genre, Title, Volume
16
+ from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Country, Genre, Title, Volume
17
17
  from ranobelib.sdk import RanobeLib
18
18
  from ranobelib.sizing import chapter_size, volume_size
19
19
 
@@ -25,6 +25,7 @@ __all__ = [
25
25
  "Chapter",
26
26
  "ChapterBranch",
27
27
  "ChapterNotFoundError",
28
+ "Country",
28
29
  "DownloadTitleInterruptedError",
29
30
  "Genre",
30
31
  "MultipleTitleTranslationsError",
@@ -13,7 +13,7 @@ from typing import Any, Self
13
13
 
14
14
  from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
15
15
  from ranobelib.client import RANOBELIB_SITE_ID, ApiClient
16
- from ranobelib.models import CatalogPage, Genre, Title
16
+ from ranobelib.models import CatalogPage, Country, 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
@@ -79,7 +79,9 @@ class Catalog:
79
79
  per_page: int = 30,
80
80
  query: str | None = None,
81
81
  genres: list[int] | None = None,
82
+ tags: list[int] | None = None,
82
83
  status: int | None = None,
84
+ country: int | None = None,
83
85
  sort: str = DEFAULT_SORT,
84
86
  refresh: bool = False,
85
87
  ) -> CatalogPage:
@@ -96,7 +98,17 @@ class Catalog:
96
98
  genres: Genre ids to filter by. A title must have *all* of them, not just one
97
99
  (AND, not OR — see docs/api-notes.md). Not validated against ``list_genres()``
98
100
  before sending — an unrecognized id just matches nothing rather than erroring.
101
+ tags: ``Tag.id``s (from ``Title.tags``) to filter by. Same AND semantics as
102
+ ``genres`` — a title must have *all* of them, not just any one (confirmed
103
+ against the live API, see docs/api-notes.md; not the OR some might expect
104
+ from tags being more numerous/specific than genres). Also not validated
105
+ before sending, same as ``genres``.
99
106
  status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
107
+ country: A single ``Country.id`` to filter by — a title only has one country of
108
+ origin, unlike ``genres``. Ids come from ``list_countries()``. Sent on the
109
+ wire as the API's ``types[]`` parameter, not a ``country``/``countries[]``
110
+ one — see docs/api-notes.md for why (``Country`` mirrors the API's own
111
+ "type" concept, which isn't strictly limited to literal countries).
100
112
  sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
101
113
  parameter — an actual ``sort`` parameter exists but is silently ignored by
102
114
  the API (see docs/api-notes.md). Known accepted values: ``"name"``,
@@ -122,7 +134,14 @@ class Catalog:
122
134
  )
123
135
 
124
136
  key = _cache_key(
125
- page=page, per_page=per_page, query=query, genres=genres, status=status, sort=sort
137
+ page=page,
138
+ per_page=per_page,
139
+ query=query,
140
+ genres=genres,
141
+ tags=tags,
142
+ status=status,
143
+ country=country,
144
+ sort=sort,
126
145
  )
127
146
  if not refresh:
128
147
  cached = self._cache.get(key)
@@ -130,7 +149,14 @@ class Catalog:
130
149
  return _build_page(cached)
131
150
 
132
151
  data = await self._client.list_titles(
133
- page=page, per_page=per_page, query=query, genres=genres, status=status, sort=sort
152
+ page=page,
153
+ per_page=per_page,
154
+ query=query,
155
+ genres=genres,
156
+ tags=tags,
157
+ status=status,
158
+ country=country,
159
+ sort=sort,
134
160
  )
135
161
  self._cache.set(key, data)
136
162
  return _build_page(data)
@@ -163,6 +189,33 @@ class Catalog:
163
189
  self._cache.set(key, data)
164
190
  return _build_genres(data)
165
191
 
192
+ async def list_countries(self, *, refresh: bool = False) -> list[Country]:
193
+ """Fetch the full list of catalog countries (id → name), for use as filter options
194
+ with ``list_titles(country=...)``.
195
+
196
+ Mirrors ``list_genres()`` exactly, down to the network-wide, not-site-scoped shape
197
+ of the underlying endpoint (``GET /api/constants?fields[]=types`` — the API's own
198
+ name for what this SDK calls "country" is "type", see docs/api-notes.md and
199
+ ``Country``'s docstring). This filters that down to entries tagged for ranobelib.me
200
+ before returning, same as ``list_genres()`` does for genres.
201
+
202
+ Args:
203
+ refresh: Bypass the disk cache and re-fetch from the API even if the country
204
+ list was already cached.
205
+
206
+ Returns:
207
+ Every ``Country`` (id, name) that applies to ranobelib.me.
208
+ """
209
+ key = "catalog:countries"
210
+ if not refresh:
211
+ cached = self._cache.get(key)
212
+ if cached is not None:
213
+ return _build_countries(cached)
214
+
215
+ data = await self._client.list_countries()
216
+ self._cache.set(key, data)
217
+ return _build_countries(data)
218
+
166
219
 
167
220
  def _cache_key(
168
221
  *,
@@ -170,11 +223,17 @@ def _cache_key(
170
223
  per_page: int,
171
224
  query: str | None,
172
225
  genres: list[int] | None,
226
+ tags: list[int] | None,
173
227
  status: int | None,
228
+ country: int | None,
174
229
  sort: str,
175
230
  ) -> str:
176
231
  genres_part = ",".join(str(genre_id) for genre_id in genres or [])
177
- return f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{status}:{sort}"
232
+ tags_part = ",".join(str(tag_id) for tag_id in tags or [])
233
+ return (
234
+ f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
235
+ f"{status}:{country}:{sort}"
236
+ )
178
237
 
179
238
 
180
239
  def _build_page(data: dict[str, Any]) -> CatalogPage:
@@ -186,3 +245,8 @@ def _build_page(data: dict[str, Any]) -> CatalogPage:
186
245
  def _build_genres(data: list[dict[str, Any]]) -> list[Genre]:
187
246
  site_id = int(RANOBELIB_SITE_ID)
188
247
  return [Genre.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
248
+
249
+
250
+ def _build_countries(data: list[dict[str, Any]]) -> list[Country]:
251
+ site_id = int(RANOBELIB_SITE_ID)
252
+ return [Country.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
@@ -170,7 +170,9 @@ class ApiClient:
170
170
  per_page: int,
171
171
  query: str | None,
172
172
  genres: list[int] | None,
173
+ tags: list[int] | None,
173
174
  status: int | None,
175
+ country: int | None,
174
176
  sort: str,
175
177
  ) -> dict[str, Any]:
176
178
  """Fetch one page of the catalog listing/search results.
@@ -187,7 +189,12 @@ class ApiClient:
187
189
  or empty omits the ``q`` parameter entirely.
188
190
  genres: Genre ids to filter by. A title must have *all* of them (AND, not OR —
189
191
  see docs/api-notes.md), not just any one.
192
+ tags: Tag ids to filter by. Same AND semantics as ``genres`` — a title must have
193
+ *all* of them, not just any one (see docs/api-notes.md).
190
194
  status: A single ``Title.status.id`` to filter by.
195
+ country: A single ``Country.id`` to filter by. Sent on the wire as ``types[]``
196
+ (repeatable array parameter, but only ever given one value here) — the API's
197
+ own name for this concept is "type", not "country" (see docs/api-notes.md).
191
198
  sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
192
199
  silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
193
200
  mismatch between the SDK's public keyword and the wire parameter name is
@@ -206,8 +213,12 @@ class ApiClient:
206
213
  params.append(("q", query))
207
214
  for genre_id in genres or []:
208
215
  params.append(("genres[]", str(genre_id)))
216
+ for tag_id in tags or []:
217
+ params.append(("tags[]", str(tag_id)))
209
218
  if status is not None:
210
219
  params.append(("status[]", str(status)))
220
+ if country is not None:
221
+ params.append(("types[]", str(country)))
211
222
 
212
223
  response = await self._get("/manga", params=httpx.QueryParams(params))
213
224
  self._raise_for_status(
@@ -235,6 +246,23 @@ class ApiClient:
235
246
  data: list[dict[str, Any]] = response.json()["data"]["genres"]
236
247
  return data
237
248
 
249
+ async def list_countries(self) -> list[dict[str, Any]]:
250
+ """Fetch the raw country/type list shared across the whole lib.social network.
251
+
252
+ Same shape and caveats as ``list_genres``: not site-scoped by any request parameter,
253
+ returns every "type" known to the network at once, each tagged with the site ids it
254
+ applies to via ``site_ids``. Named ``list_countries``/``fields[]=types`` mismatch is
255
+ deliberate — see ``Catalog.list_countries`` and docs/api-notes.md for why this reuses
256
+ the API's own "type" concept rather than the unrelated ``fields[]=countries`` field.
257
+
258
+ Returns:
259
+ The raw ``data.types`` array from the API response, unfiltered by site.
260
+ """
261
+ response = await self._get("/constants", params=httpx.QueryParams([("fields[]", "types")]))
262
+ self._raise_for_status(response, not_found=RanobeLibError("Unexpected 404 from type list"))
263
+ data: list[dict[str, Any]] = response.json()["data"]["types"]
264
+ return data
265
+
238
266
  async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
239
267
  """Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
240
268
  attempt = 0
@@ -206,6 +206,25 @@ class Tag(BaseModel):
206
206
  adult: bool = False
207
207
 
208
208
 
209
+ class Country(BaseModel):
210
+ """A title's country/region of origin.
211
+
212
+ Despite the name (matching issue #48's requested public shape), this maps onto what the
213
+ raw API itself calls "type" (`Title`'s raw `type` field; the catalog filter's `types[]`
214
+ parameter; `GET /api/constants?fields[]=types`) — not a `country`/`countries[]`/
215
+ `fields[]=countries` concept, which turned out to be something unrelated (see
216
+ docs/api-notes.md). For ranobelib.me specifically, the values are three literal
217
+ countries (Japan, Korea, China) plus three additional non-national origin categories the
218
+ site groups the same way: original English-language work, original (non-translated) web
219
+ novel, and fanfiction — surfaced as-is, not filtered down to "real" countries only.
220
+ """
221
+
222
+ model_config = ConfigDict(populate_by_name=True)
223
+
224
+ id: int
225
+ name: str = Field(alias="label")
226
+
227
+
209
228
  class Person(BaseModel):
210
229
  """An author or artist credited on a title."""
211
230
 
@@ -243,6 +262,7 @@ class Title(BaseModel):
243
262
  summary: str | None = None
244
263
  release_date: str | None = Field(default=None, alias="releaseDate")
245
264
  is_licensed: bool = False
265
+ country: Country | None = Field(default=None, alias="type")
246
266
  genres: list[Genre] = Field(default_factory=list)
247
267
  tags: list[Tag] = Field(default_factory=list)
248
268
  authors: list[Person] = Field(default_factory=list)