ranobelib-python-sdk 0.7.0__tar.gz → 0.9.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.9.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/__init__.py +2 -1
  5. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/catalog.py +72 -4
  6. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/client.py +30 -0
  7. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/models.py +20 -0
  8. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/README.md +0 -0
  9. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/cache.py +0 -0
  10. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/console.py +0 -0
  11. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/exceptions.py +0 -0
  12. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/exporters/__init__.py +0 -0
  13. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  14. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/exporters/_shared.py +0 -0
  15. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/exporters/epub.py +0 -0
  16. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/exporters/fb2.py +0 -0
  17. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/exporters/pdf.py +0 -0
  18. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/exporters/txt.py +0 -0
  19. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/numbering.py +0 -0
  20. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/py.typed +0 -0
  21. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.0}/src/ranobelib/sdk.py +0 -0
  22. {ranobelib_python_sdk-0.7.0 → ranobelib_python_sdk-0.9.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.9.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.9.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.9.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
+ countries: list[int] | None = None,
83
85
  sort: str = DEFAULT_SORT,
84
86
  refresh: bool = False,
85
87
  ) -> CatalogPage:
@@ -96,7 +98,20 @@ 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
+ countries: ``Country.id``s to filter by. A title matches if its own country is
108
+ *any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
109
+ country of origin, so requiring all of them could never match past the first,
110
+ see docs/api-notes.md). ``None`` or an empty list applies no country filter.
111
+ Ids come from ``list_countries()``. Sent on the wire as the API's ``types[]``
112
+ parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
113
+ why (``Country`` mirrors the API's own "type" concept, which isn't strictly
114
+ limited to literal countries).
100
115
  sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
101
116
  parameter — an actual ``sort`` parameter exists but is silently ignored by
102
117
  the API (see docs/api-notes.md). Known accepted values: ``"name"``,
@@ -122,7 +137,14 @@ class Catalog:
122
137
  )
123
138
 
124
139
  key = _cache_key(
125
- page=page, per_page=per_page, query=query, genres=genres, status=status, sort=sort
140
+ page=page,
141
+ per_page=per_page,
142
+ query=query,
143
+ genres=genres,
144
+ tags=tags,
145
+ status=status,
146
+ countries=countries,
147
+ sort=sort,
126
148
  )
127
149
  if not refresh:
128
150
  cached = self._cache.get(key)
@@ -130,7 +152,14 @@ class Catalog:
130
152
  return _build_page(cached)
131
153
 
132
154
  data = await self._client.list_titles(
133
- page=page, per_page=per_page, query=query, genres=genres, status=status, sort=sort
155
+ page=page,
156
+ per_page=per_page,
157
+ query=query,
158
+ genres=genres,
159
+ tags=tags,
160
+ status=status,
161
+ countries=countries,
162
+ sort=sort,
134
163
  )
135
164
  self._cache.set(key, data)
136
165
  return _build_page(data)
@@ -163,6 +192,33 @@ class Catalog:
163
192
  self._cache.set(key, data)
164
193
  return _build_genres(data)
165
194
 
195
+ async def list_countries(self, *, refresh: bool = False) -> list[Country]:
196
+ """Fetch the full list of catalog countries (id → name), for use as filter options
197
+ with ``list_titles(countries=...)``.
198
+
199
+ Mirrors ``list_genres()`` exactly, down to the network-wide, not-site-scoped shape
200
+ of the underlying endpoint (``GET /api/constants?fields[]=types`` — the API's own
201
+ name for what this SDK calls "country" is "type", see docs/api-notes.md and
202
+ ``Country``'s docstring). This filters that down to entries tagged for ranobelib.me
203
+ before returning, same as ``list_genres()`` does for genres.
204
+
205
+ Args:
206
+ refresh: Bypass the disk cache and re-fetch from the API even if the country
207
+ list was already cached.
208
+
209
+ Returns:
210
+ Every ``Country`` (id, name) that applies to ranobelib.me.
211
+ """
212
+ key = "catalog:countries"
213
+ if not refresh:
214
+ cached = self._cache.get(key)
215
+ if cached is not None:
216
+ return _build_countries(cached)
217
+
218
+ data = await self._client.list_countries()
219
+ self._cache.set(key, data)
220
+ return _build_countries(data)
221
+
166
222
 
167
223
  def _cache_key(
168
224
  *,
@@ -170,11 +226,18 @@ def _cache_key(
170
226
  per_page: int,
171
227
  query: str | None,
172
228
  genres: list[int] | None,
229
+ tags: list[int] | None,
173
230
  status: int | None,
231
+ countries: list[int] | None,
174
232
  sort: str,
175
233
  ) -> str:
176
234
  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}"
235
+ tags_part = ",".join(str(tag_id) for tag_id in tags or [])
236
+ countries_part = ",".join(str(country_id) for country_id in countries or [])
237
+ return (
238
+ f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
239
+ f"{status}:{countries_part}:{sort}"
240
+ )
178
241
 
179
242
 
180
243
  def _build_page(data: dict[str, Any]) -> CatalogPage:
@@ -186,3 +249,8 @@ def _build_page(data: dict[str, Any]) -> CatalogPage:
186
249
  def _build_genres(data: list[dict[str, Any]]) -> list[Genre]:
187
250
  site_id = int(RANOBELIB_SITE_ID)
188
251
  return [Genre.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
252
+
253
+
254
+ def _build_countries(data: list[dict[str, Any]]) -> list[Country]:
255
+ site_id = int(RANOBELIB_SITE_ID)
256
+ 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
+ countries: list[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,14 @@ 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
+ countries: ``Country.id``s to filter by. A title matches if its own country is
196
+ *any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
197
+ country, confirmed against the live API, see docs/api-notes.md). Sent on the
198
+ wire as repeated ``types[]`` parameters — the API's own name for this concept
199
+ is "type", not "country" (see docs/api-notes.md).
191
200
  sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
192
201
  silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
193
202
  mismatch between the SDK's public keyword and the wire parameter name is
@@ -206,8 +215,12 @@ class ApiClient:
206
215
  params.append(("q", query))
207
216
  for genre_id in genres or []:
208
217
  params.append(("genres[]", str(genre_id)))
218
+ for tag_id in tags or []:
219
+ params.append(("tags[]", str(tag_id)))
209
220
  if status is not None:
210
221
  params.append(("status[]", str(status)))
222
+ for country_id in countries or []:
223
+ params.append(("types[]", str(country_id)))
211
224
 
212
225
  response = await self._get("/manga", params=httpx.QueryParams(params))
213
226
  self._raise_for_status(
@@ -235,6 +248,23 @@ class ApiClient:
235
248
  data: list[dict[str, Any]] = response.json()["data"]["genres"]
236
249
  return data
237
250
 
251
+ async def list_countries(self) -> list[dict[str, Any]]:
252
+ """Fetch the raw country/type list shared across the whole lib.social network.
253
+
254
+ Same shape and caveats as ``list_genres``: not site-scoped by any request parameter,
255
+ returns every "type" known to the network at once, each tagged with the site ids it
256
+ applies to via ``site_ids``. Named ``list_countries``/``fields[]=types`` mismatch is
257
+ deliberate — see ``Catalog.list_countries`` and docs/api-notes.md for why this reuses
258
+ the API's own "type" concept rather than the unrelated ``fields[]=countries`` field.
259
+
260
+ Returns:
261
+ The raw ``data.types`` array from the API response, unfiltered by site.
262
+ """
263
+ response = await self._get("/constants", params=httpx.QueryParams([("fields[]", "types")]))
264
+ self._raise_for_status(response, not_found=RanobeLibError("Unexpected 404 from type list"))
265
+ data: list[dict[str, Any]] = response.json()["data"]["types"]
266
+ return data
267
+
238
268
  async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
239
269
  """Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
240
270
  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)