ranobelib-python-sdk 0.1.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.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: ranobelib-python-sdk
3
+ Version: 0.1.0
4
+ Summary: Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf
5
+ Keywords: ranobelib,ranobe,novel,webnovel,epub,fb2,sdk
6
+ Author: LLIJIYAPNIK
7
+ Author-email: LLIJIYAPNIK <kas4wot@yandex.ru>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: ebooklib>=0.20
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: pydantic>=2.7
21
+ Requires-Dist: weasyprint>=69.0
22
+ Requires-Python: >=3.11
23
+ Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
24
+ Project-URL: Repository, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
25
+ Project-URL: Issues, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues
26
+ Description-Content-Type: text/markdown
27
+
28
+ # ranobelib-python-sdk
29
+
30
+ [![PyPI](https://img.shields.io/pypi/v/ranobelib-python-sdk)](https://pypi.org/project/ranobelib-python-sdk/)
31
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](LICENSE)
32
+ [![Python versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
33
+ [![CI](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
34
+ [![Coverage Status](https://coveralls.io/repos/github/LLIJIYAPNIK/ranobelib-python-sdk/badge.svg?branch=main)](https://coveralls.io/github/LLIJIYAPNIK/ranobelib-python-sdk?branch=main)
35
+ [![Docs](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml)
36
+
37
+ Async Python SDK for [ranobelib.me](https://ranobelib.me): fetch title metadata, download
38
+ chapters and volumes, and export to epub, fb2, txt, or pdf.
39
+
40
+ The SDK talks to the undocumented but open JSON API behind the site (`api.cdnlibs.org`,
41
+ part of the lib.social network) instead of scraping HTML.
42
+
43
+ > **Status:** all the calls in the quickstart below are implemented — metadata, table of
44
+ > contents, single/bulk chapter and volume downloads, translation selection, and export to
45
+ > `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
46
+ > epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
47
+ > concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
48
+ > WeasyPrint's native dependencies (Pango/cairo/GTK3) installed on the system — `pip
49
+ > install`/`uv add` alone doesn't guarantee that on every platform; without them,
50
+ > `export(fmt="pdf")` raises the same "unknown format" error the other three formats don't.
51
+ > Authorization (private/18+/paid content) is out of scope — see `CLAUDE.md` for the full
52
+ > architecture and design decisions behind all of this.
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ uv add ranobelib-python-sdk
58
+ ```
59
+
60
+ or
61
+
62
+ ```bash
63
+ pip install ranobelib-python-sdk
64
+ ```
65
+
66
+ Requires Python 3.11+.
67
+
68
+ ## Quickstart
69
+
70
+ ```python
71
+ from ranobelib import RanobeLib
72
+
73
+ async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
74
+ info = await lib.get_info()
75
+ toc = await lib.get_table_of_contents()
76
+
77
+ chapter = await lib.get_chapter(volume=6, number="51.6")
78
+ volume = await lib.get_volume(volume=6)
79
+
80
+ chapters = await lib.get_chapters([(6, "51.6"), (6, "52")])
81
+ volumes = await lib.get_volumes([1, 2, 3])
82
+
83
+ translations = await lib.get_translations(volume=6, number="51.6")
84
+ chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
85
+
86
+ await lib.export(chapters, fmt="epub", path="output.epub")
87
+ ```
88
+
89
+ This is the full current public API — everything above is implemented and works today.
90
+
91
+ ## Documentation
92
+
93
+ Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
94
+ (built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
95
+ documents the full architecture, public API contract, and the reasoning behind the SDK's
96
+ design decisions.
97
+
98
+ ## License
99
+
100
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,73 @@
1
+ # ranobelib-python-sdk
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ranobelib-python-sdk)](https://pypi.org/project/ranobelib-python-sdk/)
4
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](LICENSE)
5
+ [![Python versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
6
+ [![CI](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
7
+ [![Coverage Status](https://coveralls.io/repos/github/LLIJIYAPNIK/ranobelib-python-sdk/badge.svg?branch=main)](https://coveralls.io/github/LLIJIYAPNIK/ranobelib-python-sdk?branch=main)
8
+ [![Docs](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml)
9
+
10
+ Async Python SDK for [ranobelib.me](https://ranobelib.me): fetch title metadata, download
11
+ chapters and volumes, and export to epub, fb2, txt, or pdf.
12
+
13
+ The SDK talks to the undocumented but open JSON API behind the site (`api.cdnlibs.org`,
14
+ part of the lib.social network) instead of scraping HTML.
15
+
16
+ > **Status:** all the calls in the quickstart below are implemented — metadata, table of
17
+ > contents, single/bulk chapter and volume downloads, translation selection, and export to
18
+ > `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
19
+ > epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
20
+ > concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
21
+ > WeasyPrint's native dependencies (Pango/cairo/GTK3) installed on the system — `pip
22
+ > install`/`uv add` alone doesn't guarantee that on every platform; without them,
23
+ > `export(fmt="pdf")` raises the same "unknown format" error the other three formats don't.
24
+ > Authorization (private/18+/paid content) is out of scope — see `CLAUDE.md` for the full
25
+ > architecture and design decisions behind all of this.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ uv add ranobelib-python-sdk
31
+ ```
32
+
33
+ or
34
+
35
+ ```bash
36
+ pip install ranobelib-python-sdk
37
+ ```
38
+
39
+ Requires Python 3.11+.
40
+
41
+ ## Quickstart
42
+
43
+ ```python
44
+ from ranobelib import RanobeLib
45
+
46
+ async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
47
+ info = await lib.get_info()
48
+ toc = await lib.get_table_of_contents()
49
+
50
+ chapter = await lib.get_chapter(volume=6, number="51.6")
51
+ volume = await lib.get_volume(volume=6)
52
+
53
+ chapters = await lib.get_chapters([(6, "51.6"), (6, "52")])
54
+ volumes = await lib.get_volumes([1, 2, 3])
55
+
56
+ translations = await lib.get_translations(volume=6, number="51.6")
57
+ chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
58
+
59
+ await lib.export(chapters, fmt="epub", path="output.epub")
60
+ ```
61
+
62
+ This is the full current public API — everything above is implemented and works today.
63
+
64
+ ## Documentation
65
+
66
+ Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
67
+ (built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
68
+ documents the full architecture, public API contract, and the reasoning behind the SDK's
69
+ design decisions.
70
+
71
+ ## License
72
+
73
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,115 @@
1
+ [project]
2
+ name = "ranobelib-python-sdk"
3
+ version = "0.1.0"
4
+ description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ keywords = [
9
+ "ranobelib",
10
+ "ranobe",
11
+ "novel",
12
+ "webnovel",
13
+ "epub",
14
+ "fb2",
15
+ "sdk",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 2 - Pre-Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = [
29
+ "ebooklib>=0.20",
30
+ "httpx>=0.27",
31
+ "pydantic>=2.7",
32
+ "weasyprint>=69.0",
33
+ ]
34
+
35
+ [[project.authors]]
36
+ name = "LLIJIYAPNIK"
37
+ email = "kas4wot@yandex.ru"
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
41
+ Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
42
+ Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.11.26,<0.12.0"]
46
+ build-backend = "uv_build"
47
+
48
+ [tool.uv.build-backend]
49
+ module-name = "ranobelib"
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py311"
54
+ src = ["src"]
55
+ extend-exclude = ["CLAUDE.md"]
56
+
57
+ [tool.ruff.lint]
58
+ select = [
59
+ "E",
60
+ "F",
61
+ "I",
62
+ "UP",
63
+ "B",
64
+ "SIM",
65
+ "C4",
66
+ ]
67
+
68
+ [tool.ruff.format]
69
+ quote-style = "double"
70
+
71
+ [tool.mypy]
72
+ python_version = "3.11"
73
+ strict = true
74
+ files = ["src"]
75
+
76
+ [[tool.mypy.overrides]]
77
+ module = [
78
+ "ebooklib",
79
+ "ebooklib.*",
80
+ "weasyprint",
81
+ "weasyprint.*",
82
+ ]
83
+ ignore_missing_imports = true
84
+
85
+ [tool.pytest.ini_options]
86
+ asyncio_mode = "auto"
87
+ testpaths = ["tests"]
88
+
89
+ [tool.coverage.run]
90
+ source = ["ranobelib"]
91
+
92
+ [tool.coverage.report]
93
+ fail_under = 95
94
+ exclude_also = [
95
+ "if TYPE_CHECKING:",
96
+ '\.\.\.',
97
+ ]
98
+
99
+ [dependency-groups]
100
+ dev = [
101
+ "coveralls>=4.0",
102
+ "lxml>=5.0",
103
+ "mypy>=1.10",
104
+ "pypdf>=6.14.2",
105
+ "pytest>=8.0",
106
+ "pytest-asyncio>=0.24",
107
+ "pytest-cov>=5.0",
108
+ "pytest-recording>=0.13",
109
+ "ruff>=0.6",
110
+ "vcrpy>=6.0",
111
+ ]
112
+ docs = [
113
+ "mkdocs-material>=9.5",
114
+ "mkdocstrings[python]>=0.25",
115
+ ]
@@ -0,0 +1,94 @@
1
+ [project]
2
+ name = "ranobelib-python-sdk"
3
+ version = "0.1.0"
4
+ description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ authors = [
9
+ { name = "LLIJIYAPNIK", email = "kas4wot@yandex.ru" }
10
+ ]
11
+ keywords = ["ranobelib", "ranobe", "novel", "webnovel", "epub", "fb2", "sdk"]
12
+ classifiers = [
13
+ "Development Status :: 2 - Pre-Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "ebooklib>=0.20",
25
+ "httpx>=0.27",
26
+ "pydantic>=2.7",
27
+ "weasyprint>=69.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
32
+ Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
33
+ Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.11.26,<0.12.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.uv.build-backend]
40
+ module-name = "ranobelib"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "coveralls>=4.0",
45
+ "lxml>=5.0",
46
+ "mypy>=1.10",
47
+ "pypdf>=6.14.2",
48
+ "pytest>=8.0",
49
+ "pytest-asyncio>=0.24",
50
+ "pytest-cov>=5.0",
51
+ "pytest-recording>=0.13",
52
+ "ruff>=0.6",
53
+ "vcrpy>=6.0",
54
+ ]
55
+ docs = [
56
+ "mkdocs-material>=9.5",
57
+ "mkdocstrings[python]>=0.25",
58
+ ]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py311"
63
+ src = ["src"]
64
+ extend-exclude = ["CLAUDE.md"]
65
+
66
+ [tool.ruff.lint]
67
+ select = ["E", "F", "I", "UP", "B", "SIM", "C4"]
68
+
69
+ [tool.ruff.format]
70
+ quote-style = "double"
71
+
72
+ [tool.mypy]
73
+ python_version = "3.11"
74
+ strict = true
75
+ files = ["src"]
76
+
77
+ [[tool.mypy.overrides]]
78
+ # ebooklib and weasyprint ship no type stubs / py.typed marker.
79
+ module = ["ebooklib", "ebooklib.*", "weasyprint", "weasyprint.*"]
80
+ ignore_missing_imports = true
81
+
82
+ [tool.pytest.ini_options]
83
+ asyncio_mode = "auto"
84
+ testpaths = ["tests"]
85
+
86
+ [tool.coverage.run]
87
+ source = ["ranobelib"]
88
+
89
+ [tool.coverage.report]
90
+ fail_under = 95
91
+ exclude_also = [
92
+ "if TYPE_CHECKING:",
93
+ "\\.\\.\\.",
94
+ ]
@@ -0,0 +1,28 @@
1
+ """Async Python SDK for ranobelib.me."""
2
+
3
+ from ranobelib.exceptions import (
4
+ AuthRequiredError,
5
+ ChapterNotFoundError,
6
+ MultipleTranslationsError,
7
+ RanobeLibError,
8
+ RateLimitError,
9
+ TitleNotFoundError,
10
+ VolumeNotFoundError,
11
+ )
12
+ from ranobelib.models import Chapter, ChapterBranch, Title, Volume
13
+ from ranobelib.sdk import RanobeLib
14
+
15
+ __all__ = [
16
+ "AuthRequiredError",
17
+ "Chapter",
18
+ "ChapterBranch",
19
+ "ChapterNotFoundError",
20
+ "MultipleTranslationsError",
21
+ "RanobeLib",
22
+ "RanobeLibError",
23
+ "RateLimitError",
24
+ "Title",
25
+ "TitleNotFoundError",
26
+ "Volume",
27
+ "VolumeNotFoundError",
28
+ ]
@@ -0,0 +1,64 @@
1
+ """Disk cache for raw JSON API responses, keyed by request identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ DEFAULT_CACHE_DIR = Path(".ranobelib_cache")
13
+ """Default cache directory, relative to the current working directory."""
14
+
15
+
16
+ class DiskCache:
17
+ """Caches JSON-serializable values to disk, keyed by an arbitrary string.
18
+
19
+ One file per key, named by the key's hash (so keys built from slugs, chapter numbers,
20
+ etc. are always safe filenames), holding the cached value plus the time it was written
21
+ so ``ttl`` can be checked on read without touching any other entry. Corrupted or
22
+ unreadable entries are treated as a cache miss rather than raised as an error, since a
23
+ stale/broken cache file shouldn't stop the SDK from just re-fetching the data.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ cache_dir: str | Path,
29
+ *,
30
+ ttl: float | None = None,
31
+ clock: Callable[[], float] = time.time,
32
+ ) -> None:
33
+ """Initialize the cache.
34
+
35
+ Args:
36
+ cache_dir: Directory to store cache files in. Created lazily on first write.
37
+ ttl: Seconds after which a cache entry is treated as a miss. ``None`` (the
38
+ default) means entries never expire.
39
+ clock: Time source used to stamp and check entries. Overridable for tests.
40
+ """
41
+ self._dir = Path(cache_dir)
42
+ self._ttl = ttl
43
+ self._clock = clock
44
+
45
+ def get(self, key: str) -> Any | None:
46
+ """Return the cached value for ``key``, or ``None`` on a miss or expiry."""
47
+ try:
48
+ payload = json.loads(self._path_for(key).read_text(encoding="utf-8"))
49
+ except (OSError, json.JSONDecodeError, KeyError):
50
+ return None
51
+ if self._ttl is not None and self._clock() - payload["cached_at"] > self._ttl:
52
+ return None
53
+ return payload["data"]
54
+
55
+ def set(self, key: str, data: Any) -> None:
56
+ """Write ``data`` to disk under ``key``, creating the cache directory if needed."""
57
+ path = self._path_for(key)
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ payload = {"cached_at": self._clock(), "data": data}
60
+ path.write_text(json.dumps(payload), encoding="utf-8")
61
+
62
+ def _path_for(self, key: str) -> Path:
63
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
64
+ return self._dir / f"{digest}.json"
@@ -0,0 +1,210 @@
1
+ """Low-level HTTP client for the api.cdnlibs.org JSON API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from types import TracebackType
9
+ from typing import Any, Self
10
+
11
+ import httpx
12
+
13
+ from ranobelib.exceptions import (
14
+ AuthRequiredError,
15
+ ChapterNotFoundError,
16
+ RanobeLibError,
17
+ RateLimitError,
18
+ TitleNotFoundError,
19
+ )
20
+
21
+ API_BASE_URL = "https://api.cdnlibs.org/api"
22
+ """Base URL of the lib.social JSON API shared across the network's sites."""
23
+
24
+ RANOBELIB_SITE_ID = "3"
25
+ """``Site-Id`` header value that scopes requests to ranobelib.me. See docs/api-notes.md."""
26
+
27
+ _RETRYABLE_STATUSES = frozenset({429, *range(500, 600)})
28
+
29
+ DEFAULT_REQUEST_DELAY = 0.2
30
+ """Default minimum time, in seconds, between the start of one request and the next."""
31
+
32
+
33
+ class ApiClient:
34
+ """Thin async wrapper around the undocumented api.cdnlibs.org JSON API.
35
+
36
+ Bounds concurrency with a semaphore, paces request starts with a small fixed delay, and
37
+ retries 429/5xx responses with exponential backoff — see docs/api-notes.md for why a
38
+ manual implementation was chosen over a dependency like ``tenacity`` for this.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ base_url: str = API_BASE_URL,
45
+ timeout: float = 15.0,
46
+ transport: httpx.AsyncBaseTransport | None = None,
47
+ max_concurrency: int = 5,
48
+ request_delay: float | None = None,
49
+ max_retries: int = 3,
50
+ retry_base_delay: float = 1.0,
51
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
52
+ clock: Callable[[], float] = time.monotonic,
53
+ ) -> None:
54
+ """Initialize the client.
55
+
56
+ Args:
57
+ base_url: API base URL.
58
+ timeout: Per-request timeout, in seconds.
59
+ transport: Custom ``httpx`` transport, e.g. a mock transport for tests.
60
+ max_concurrency: Maximum number of requests in flight at once.
61
+ request_delay: Minimum time, in seconds, between the start of one request and
62
+ the next, even under ``max_concurrency``. ``None`` (the default) uses
63
+ ``DEFAULT_REQUEST_DELAY``.
64
+ max_retries: How many times to retry a 429 or 5xx response before giving up and
65
+ raising.
66
+ retry_base_delay: Base delay, in seconds, for exponential backoff between
67
+ retries (doubled per attempt) — used unless a 429 response's
68
+ ``Retry-After`` header says otherwise.
69
+ sleep: Sleep function used for pacing and backoff. Overridable for tests.
70
+ clock: Time source used for pacing. Overridable for tests.
71
+ """
72
+ self._http = httpx.AsyncClient(
73
+ base_url=base_url,
74
+ timeout=timeout,
75
+ transport=transport,
76
+ headers={
77
+ "Site-Id": RANOBELIB_SITE_ID,
78
+ "Accept": "application/json",
79
+ },
80
+ )
81
+ self._semaphore = asyncio.Semaphore(max_concurrency)
82
+ self._request_delay = DEFAULT_REQUEST_DELAY if request_delay is None else request_delay
83
+ self._max_retries = max_retries
84
+ self._retry_base_delay = retry_base_delay
85
+ self._sleep = sleep
86
+ self._clock = clock
87
+ self._pacing_lock = asyncio.Lock()
88
+ self._last_request_at: float | None = None
89
+
90
+ async def __aenter__(self) -> Self:
91
+ return self
92
+
93
+ async def __aexit__(
94
+ self,
95
+ exc_type: type[BaseException] | None,
96
+ exc: BaseException | None,
97
+ traceback: TracebackType | None,
98
+ ) -> None:
99
+ await self.aclose()
100
+
101
+ async def aclose(self) -> None:
102
+ """Close the underlying HTTP connection pool."""
103
+ await self._http.aclose()
104
+
105
+ async def get_title(self, slug_url: str, *, fields: list[str] | None = None) -> dict[str, Any]:
106
+ """Fetch raw title metadata for ``slug_url``.
107
+
108
+ Args:
109
+ slug_url: The title's ``{id}--{slug}`` identifier.
110
+ fields: Extra ``fields[]`` values to request from the API.
111
+
112
+ Returns:
113
+ The ``data`` object from the API response.
114
+ """
115
+ params = httpx.QueryParams([("fields[]", field) for field in fields or []])
116
+ response = await self._get(f"/manga/{slug_url}", params=params)
117
+ self._raise_for_status(response, not_found=TitleNotFoundError(slug_url))
118
+ data: dict[str, Any] = response.json()["data"]
119
+ return data
120
+
121
+ async def get_chapters(self, slug_url: str) -> list[dict[str, Any]]:
122
+ """Fetch the raw chapter list for ``slug_url`` (single request, no pagination).
123
+
124
+ Args:
125
+ slug_url: The title's ``{id}--{slug}`` identifier.
126
+
127
+ Returns:
128
+ The ``data`` array from the API response.
129
+ """
130
+ response = await self._get(f"/manga/{slug_url}/chapters")
131
+ self._raise_for_status(response, not_found=TitleNotFoundError(slug_url))
132
+ data: list[dict[str, Any]] = response.json()["data"]
133
+ return data
134
+
135
+ async def get_chapter(
136
+ self,
137
+ slug_url: str,
138
+ *,
139
+ number: str,
140
+ volume: str,
141
+ branch_id: int | None = None,
142
+ ) -> dict[str, Any]:
143
+ """Fetch a single chapter's metadata and content.
144
+
145
+ Args:
146
+ slug_url: The title's ``{id}--{slug}`` identifier.
147
+ number: The chapter number, as returned by the API (may contain a decimal).
148
+ volume: The volume number.
149
+ branch_id: Which translation team's branch to fetch, for chapters with more
150
+ than one. Defaults to whichever branch the API picks (see
151
+ docs/api-notes.md — this is not simply the first branch listed).
152
+
153
+ Returns:
154
+ The ``data`` object from the API response.
155
+ """
156
+ params = {"number": number, "volume": volume}
157
+ if branch_id is not None:
158
+ params["branch_id"] = str(branch_id)
159
+ response = await self._get(f"/manga/{slug_url}/chapter", params=params)
160
+ self._raise_for_status(
161
+ response, not_found=ChapterNotFoundError(slug_url, volume=volume, number=number)
162
+ )
163
+ data: dict[str, Any] = response.json()["data"]
164
+ return data
165
+
166
+ async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
167
+ """Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
168
+ attempt = 0
169
+ while True:
170
+ async with self._semaphore:
171
+ await self._pace()
172
+ response = await self._http.get(url, params=params)
173
+ if response.status_code not in _RETRYABLE_STATUSES or attempt >= self._max_retries:
174
+ return response
175
+ await self._sleep(self._backoff_delay(attempt, response))
176
+ attempt += 1
177
+
178
+ async def _pace(self) -> None:
179
+ """Wait, if needed, so this request starts at least ``request_delay`` after the last."""
180
+ async with self._pacing_lock:
181
+ now = self._clock()
182
+ if self._last_request_at is not None:
183
+ wait = self._last_request_at + self._request_delay - now
184
+ if wait > 0:
185
+ await self._sleep(wait)
186
+ self._last_request_at = self._clock()
187
+
188
+ def _backoff_delay(self, attempt: int, response: httpx.Response) -> float:
189
+ if response.status_code == 429:
190
+ retry_after = response.headers.get("Retry-After")
191
+ if retry_after is not None:
192
+ try:
193
+ return float(retry_after)
194
+ except ValueError:
195
+ pass
196
+ delay: float = self._retry_base_delay * (2**attempt)
197
+ return delay
198
+
199
+ def _raise_for_status(self, response: httpx.Response, *, not_found: RanobeLibError) -> None:
200
+ if response.status_code == 404:
201
+ raise not_found
202
+ if response.status_code == 403:
203
+ raise AuthRequiredError(str(response.request.url))
204
+ if response.status_code == 429:
205
+ retry_after = response.headers.get("Retry-After")
206
+ raise RateLimitError(float(retry_after) if retry_after else None)
207
+ try:
208
+ response.raise_for_status()
209
+ except httpx.HTTPStatusError as exc:
210
+ raise RanobeLibError(f"Unexpected API response: {exc}") from exc