libsocial-core 1.0.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,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: libsocial-core
3
+ Version: 1.0.0
4
+ Summary: Async core shared by the lib.social site SDKs: api.cdnlibs.org client, caching, titles, chapters and catalog
5
+ Keywords: lib.social,ranobelib,mangalib,cdnlibs,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: httpx>=0.27
19
+ Requires-Dist: pydantic>=2.7
20
+ Requires-Dist: rich>=15.0.0
21
+ Requires-Python: >=3.11
22
+ Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
23
+ Project-URL: Repository, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
24
+ Project-URL: Issues, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # libsocial-core
28
+
29
+ The site-independent core shared by the async Python SDKs for the lib.social sites
30
+ (ranobelib.me, mangalib.me, ...). They all sit on one backend, `api.cdnlibs.org`, and differ
31
+ mainly in the `Site-Id` they send and in what a chapter contains.
32
+
33
+ This package is the shared part:
34
+
35
+ - `ApiClient`: the `api.cdnlibs.org` client, scoped to a site by a `Site` (id + origin).
36
+ Bounded concurrency, retries with backoff on 429/5xx, and request pacing shared by every
37
+ client in the process that talks to the same host.
38
+ - `TitleClient`: one title's metadata, table of contents, translations (branches), chapter
39
+ fetching and whole-title download, with a disk cache.
40
+ - `Catalog`: catalog listing/search and the genre/country/status lists, filtered to the site.
41
+ - Models (`Title`, `Chapter`, `ChapterBranch`, `Volume`, ...) and exceptions.
42
+
43
+ It's meant to be used through a site package, which sets the site and its chapter model —
44
+ most likely [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), not
45
+ directly.
46
+
47
+ ```python
48
+ from libsocial import Site, TitleClient
49
+ from libsocial.models import Chapter, Title, Volume
50
+
51
+
52
+ class MyChapter(Chapter):
53
+ content: str | None = None
54
+
55
+
56
+ class MyVolume(Volume[MyChapter]):
57
+ pass
58
+
59
+
60
+ class MySite(TitleClient[Title, MyChapter, MyVolume]):
61
+ site = Site(id=3, origin="https://ranobelib.me")
62
+ title_model = Title
63
+ chapter_model = MyChapter
64
+ volume_model = MyVolume
65
+
66
+
67
+ async with MySite("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as client:
68
+ info = await client.get_info()
69
+ ```
70
+
71
+ Repository, documentation and the site packages:
72
+ <https://github.com/LLIJIYAPNIK/ranobelib-python-sdk>.
73
+
74
+ MIT licensed.
@@ -0,0 +1,48 @@
1
+ # libsocial-core
2
+
3
+ The site-independent core shared by the async Python SDKs for the lib.social sites
4
+ (ranobelib.me, mangalib.me, ...). They all sit on one backend, `api.cdnlibs.org`, and differ
5
+ mainly in the `Site-Id` they send and in what a chapter contains.
6
+
7
+ This package is the shared part:
8
+
9
+ - `ApiClient`: the `api.cdnlibs.org` client, scoped to a site by a `Site` (id + origin).
10
+ Bounded concurrency, retries with backoff on 429/5xx, and request pacing shared by every
11
+ client in the process that talks to the same host.
12
+ - `TitleClient`: one title's metadata, table of contents, translations (branches), chapter
13
+ fetching and whole-title download, with a disk cache.
14
+ - `Catalog`: catalog listing/search and the genre/country/status lists, filtered to the site.
15
+ - Models (`Title`, `Chapter`, `ChapterBranch`, `Volume`, ...) and exceptions.
16
+
17
+ It's meant to be used through a site package, which sets the site and its chapter model —
18
+ most likely [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), not
19
+ directly.
20
+
21
+ ```python
22
+ from libsocial import Site, TitleClient
23
+ from libsocial.models import Chapter, Title, Volume
24
+
25
+
26
+ class MyChapter(Chapter):
27
+ content: str | None = None
28
+
29
+
30
+ class MyVolume(Volume[MyChapter]):
31
+ pass
32
+
33
+
34
+ class MySite(TitleClient[Title, MyChapter, MyVolume]):
35
+ site = Site(id=3, origin="https://ranobelib.me")
36
+ title_model = Title
37
+ chapter_model = MyChapter
38
+ volume_model = MyVolume
39
+
40
+
41
+ async with MySite("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as client:
42
+ info = await client.get_info()
43
+ ```
44
+
45
+ Repository, documentation and the site packages:
46
+ <https://github.com/LLIJIYAPNIK/ranobelib-python-sdk>.
47
+
48
+ MIT licensed.
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "libsocial-core"
3
+ version = "1.0.0"
4
+ description = "Async core shared by the lib.social site SDKs: api.cdnlibs.org client, caching, titles, chapters and catalog"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ keywords = [
9
+ "lib.social",
10
+ "ranobelib",
11
+ "mangalib",
12
+ "cdnlibs",
13
+ "sdk",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 2 - Pre-Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "httpx>=0.27",
28
+ "pydantic>=2.7",
29
+ "rich>=15.0.0",
30
+ ]
31
+
32
+ [[project.authors]]
33
+ name = "LLIJIYAPNIK"
34
+ email = "kas4wot@yandex.ru"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
38
+ Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
39
+ Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.11.26,<0.12.0"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.uv.build-backend]
46
+ module-name = "libsocial"
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "pytest>=8.0",
51
+ "pytest-asyncio>=0.24",
52
+ "pytest-cov>=5.0",
53
+ ]
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "libsocial-core"
3
+ version = "1.0.0"
4
+ description = "Async core shared by the lib.social site SDKs: api.cdnlibs.org client, caching, titles, chapters and catalog"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ authors = [
9
+ { name = "LLIJIYAPNIK", email = "kas4wot@yandex.ru" }
10
+ ]
11
+ keywords = ["lib.social", "ranobelib", "mangalib", "cdnlibs", "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
+ "httpx>=0.27",
25
+ "pydantic>=2.7",
26
+ "rich>=15.0.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
31
+ Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
32
+ Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.11.26,<0.12.0"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.uv.build-backend]
39
+ module-name = "libsocial"
40
+
41
+ # Test stack for this package only — see packages/ranobelib/pyproject.toml for why it isn't
42
+ # in the workspace root's dev group.
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=8.0",
46
+ "pytest-asyncio>=0.24",
47
+ "pytest-cov>=5.0",
48
+ ]
@@ -0,0 +1,81 @@
1
+ """Site-independent core of the lib.social SDKs (ranobelib.me, mangalib.me, ...)."""
2
+
3
+ from libsocial._http import browser_headers, browser_headers_for
4
+ from libsocial.cache import CacheStats, cache_stats, clear_cache
5
+ from libsocial.catalog import Catalog
6
+ from libsocial.client import ApiClient
7
+ from libsocial.console import Reporter, Verbosity
8
+ from libsocial.exceptions import (
9
+ AccessBlockedError,
10
+ AmbiguousChapter,
11
+ AuthRequiredError,
12
+ ChapterNotFoundError,
13
+ DownloadTitleInterruptedError,
14
+ LibSocialError,
15
+ MultipleTitleTranslationsError,
16
+ MultipleTranslationsError,
17
+ RateLimitError,
18
+ TitleNotFoundError,
19
+ VolumeNotFoundError,
20
+ )
21
+ from libsocial.models import (
22
+ CatalogPage,
23
+ Chapter,
24
+ ChapterBranch,
25
+ ChapterUser,
26
+ Country,
27
+ Cover,
28
+ Genre,
29
+ Label,
30
+ Person,
31
+ Tag,
32
+ Team,
33
+ Title,
34
+ Volume,
35
+ )
36
+ from libsocial.numbering import parse_slug_url
37
+ from libsocial.pacing import Pacer, shared_pacer
38
+ from libsocial.site import Site
39
+ from libsocial.title import PlannedChapter, TitleClient
40
+
41
+ __all__ = [
42
+ "AccessBlockedError",
43
+ "AmbiguousChapter",
44
+ "ApiClient",
45
+ "AuthRequiredError",
46
+ "CacheStats",
47
+ "Catalog",
48
+ "CatalogPage",
49
+ "Chapter",
50
+ "ChapterBranch",
51
+ "ChapterNotFoundError",
52
+ "ChapterUser",
53
+ "Country",
54
+ "Cover",
55
+ "DownloadTitleInterruptedError",
56
+ "Genre",
57
+ "Label",
58
+ "LibSocialError",
59
+ "MultipleTitleTranslationsError",
60
+ "MultipleTranslationsError",
61
+ "Pacer",
62
+ "Person",
63
+ "PlannedChapter",
64
+ "RateLimitError",
65
+ "Reporter",
66
+ "Site",
67
+ "Tag",
68
+ "Team",
69
+ "Title",
70
+ "TitleClient",
71
+ "TitleNotFoundError",
72
+ "Verbosity",
73
+ "Volume",
74
+ "VolumeNotFoundError",
75
+ "browser_headers",
76
+ "browser_headers_for",
77
+ "cache_stats",
78
+ "clear_cache",
79
+ "parse_slug_url",
80
+ "shared_pacer",
81
+ ]
@@ -0,0 +1,45 @@
1
+ """HTTP headers shared by every client the SDKs open (not public API).
2
+
3
+ Lives in its own module rather than in ``client.py`` because it isn't specific to the
4
+ api.cdnlibs.org JSON API: a site package's image-downloading clients (cover.cdnlibs.org,
5
+ the site's own uploads) need the same headers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+
12
+ import httpx
13
+
14
+ BROWSER_USER_AGENT = (
15
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 "
16
+ "(KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
17
+ )
18
+ """A real browser's ``User-Agent``, sent instead of advertising ``python-httpx``."""
19
+
20
+
21
+ def browser_headers_for(origin: str) -> dict[str, str]:
22
+ """Headers a browser on the site at ``origin`` sends, so requests look like the site's.
23
+
24
+ The DDoS-Guard edge in front of api.cdnlibs.org and cover.cdnlibs.org rejects requests
25
+ without a non-empty ``Referer`` with an HTML 403 page (see docs/api-notes.md, section
26
+ "WAF 403"). Any ``Referer`` value was enough when checked; the site's own one is sent,
27
+ together with ``Origin`` and a real browser ``User-Agent``, so the request as a whole
28
+ matches what the site itself sends.
29
+
30
+ Args:
31
+ origin: The site's web origin, e.g. ``"https://ranobelib.me"`` (see ``Site.origin``).
32
+ """
33
+ return {"Origin": origin, "Referer": f"{origin}/", "User-Agent": BROWSER_USER_AGENT}
34
+
35
+
36
+ def browser_headers(origin: str, overrides: Mapping[str, str] | None = None) -> httpx.Headers:
37
+ """``browser_headers_for(origin)`` with ``overrides`` merged over them.
38
+
39
+ A key in ``overrides`` replaces the default one, matched case-insensitively (``httpx``
40
+ headers semantics) — the same merge ``ApiClient(headers=...)`` does for API requests.
41
+ """
42
+ headers = httpx.Headers(browser_headers_for(origin))
43
+ if overrides:
44
+ headers.update(overrides)
45
+ return headers
@@ -0,0 +1,180 @@
1
+ """Disk cache for raw JSON API responses, keyed by request identity.
2
+
3
+ Also the cache-maintenance helpers, ``cache_stats()`` and ``clear_cache()`` — the
4
+ on-disk layout is internal, so code outside the SDKs should go through these rather than
5
+ measure or delete the cache's files itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import time
14
+ from collections.abc import Callable, Iterator
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ DEFAULT_CACHE_DIR = Path(".libsocial_cache")
20
+ """Default cache directory, relative to the current working directory."""
21
+
22
+ _ENTRY_NAME = re.compile(r"[0-9a-f]{64}\.json")
23
+ """Name of a file ``DiskCache`` writes (``_path_for``) — anything else in the directory
24
+ isn't the cache's and is never counted or removed."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CacheStats:
29
+ """How many cache entries there are (or were removed), and their total size on disk."""
30
+
31
+ entries: int
32
+ bytes: int
33
+
34
+
35
+ class DiskCache:
36
+ """Caches JSON-serializable values to disk, keyed by an arbitrary string.
37
+
38
+ One file per key, named by the key's hash (so keys built from slugs, chapter numbers,
39
+ etc. are always safe filenames), holding the cached value plus the time it was written
40
+ so ``ttl`` can be checked on read without touching any other entry. Corrupted or
41
+ unreadable entries are treated as a cache miss rather than raised as an error, since a
42
+ stale/broken cache file shouldn't stop the SDK from just re-fetching the data.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ cache_dir: str | Path,
48
+ *,
49
+ ttl: float | None = None,
50
+ clock: Callable[[], float] = time.time,
51
+ ) -> None:
52
+ """Initialize the cache.
53
+
54
+ Args:
55
+ cache_dir: Directory to store cache files in. Created lazily on first write.
56
+ ttl: Seconds after which a cache entry is treated as a miss. ``None`` (the
57
+ default) means entries never expire.
58
+ clock: Time source used to stamp and check entries. Overridable for tests.
59
+ """
60
+ self._dir = Path(cache_dir)
61
+ self._ttl = ttl
62
+ self._clock = clock
63
+
64
+ def get(self, key: str) -> Any | None:
65
+ """Return the cached value for ``key``, or ``None`` on a miss or expiry."""
66
+ try:
67
+ payload = json.loads(self._path_for(key).read_text(encoding="utf-8"))
68
+ except (OSError, json.JSONDecodeError, KeyError):
69
+ return None
70
+ if self._ttl is not None and self._clock() - payload["cached_at"] > self._ttl:
71
+ return None
72
+ return payload["data"]
73
+
74
+ def set(self, key: str, data: Any) -> None:
75
+ """Write ``data`` to disk under ``key``, creating the cache directory if needed."""
76
+ path = self._path_for(key)
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ payload = {"cached_at": self._clock(), "data": data}
79
+ path.write_text(json.dumps(payload), encoding="utf-8")
80
+
81
+ def stats(self) -> CacheStats:
82
+ """Count the cache's entries and their total size. See ``cache_stats()``."""
83
+ entries = size = 0
84
+ for path in self._entry_paths():
85
+ try:
86
+ size += path.stat().st_size
87
+ except OSError: # Removed since it was listed.
88
+ continue
89
+ entries += 1
90
+ return CacheStats(entries=entries, bytes=size)
91
+
92
+ def clear(self, *, older_than: float | None = None) -> CacheStats:
93
+ """Remove the cache's entries, or only those older than ``older_than`` seconds.
94
+
95
+ See ``clear_cache()``.
96
+ """
97
+ if older_than is not None and older_than < 0:
98
+ raise ValueError(f"older_than must be >= 0, got {older_than}")
99
+ now = self._clock()
100
+ entries = size = 0
101
+ for path in self._entry_paths():
102
+ if older_than is not None and not self._is_older_than(path, now - older_than):
103
+ continue
104
+ try:
105
+ entry_size = path.stat().st_size
106
+ path.unlink()
107
+ except OSError:
108
+ # Already removed by someone else, or (on Windows) open in another process
109
+ # right now — either way, not ours to count as removed.
110
+ continue
111
+ entries += 1
112
+ size += entry_size
113
+ return CacheStats(entries=entries, bytes=size)
114
+
115
+ def _is_older_than(self, path: Path, cutoff: float) -> bool:
116
+ try:
117
+ cached_at = float(json.loads(path.read_text(encoding="utf-8"))["cached_at"])
118
+ except FileNotFoundError:
119
+ return False
120
+ except (OSError, ValueError, KeyError, TypeError):
121
+ # Unreadable/corrupted: get() can never serve it, so it's as good as expired.
122
+ return True
123
+ return cached_at < cutoff
124
+
125
+ def _entry_paths(self) -> Iterator[Path]:
126
+ try:
127
+ children = list(self._dir.iterdir())
128
+ except (FileNotFoundError, NotADirectoryError):
129
+ return
130
+ for path in children:
131
+ if _ENTRY_NAME.fullmatch(path.name) and path.is_file() and not path.is_symlink():
132
+ yield path
133
+
134
+ def _path_for(self, key: str) -> Path:
135
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
136
+ return self._dir / f"{digest}.json"
137
+
138
+
139
+ def cache_stats(cache_dir: str | Path = DEFAULT_CACHE_DIR) -> CacheStats:
140
+ """Count the entries in a disk cache and their total size on disk.
141
+
142
+ Only files the cache itself writes are counted, not anything else that happens to be in
143
+ the directory.
144
+
145
+ Args:
146
+ cache_dir: The cache directory — the same ``cache_dir`` passed to ``TitleClient`` /
147
+ ``Catalog`` (default: ``DEFAULT_CACHE_DIR``, ``.libsocial_cache`` in the current
148
+ directory; a site package may use its own default).
149
+
150
+ Returns:
151
+ The number of entries and their total size in bytes. A missing directory is an empty
152
+ cache, ``CacheStats(entries=0, bytes=0)``, not an error.
153
+ """
154
+ return DiskCache(cache_dir).stats()
155
+
156
+
157
+ def clear_cache(
158
+ cache_dir: str | Path = DEFAULT_CACHE_DIR, *, older_than: float | None = None
159
+ ) -> CacheStats:
160
+ """Remove entries from a disk cache.
161
+
162
+ Only files the cache itself writes are removed — anything else in the directory, and the
163
+ directory itself, is left alone. Safe to call while clients are using the same cache: an
164
+ entry being read as it's removed is just a cache miss, and one written after it's
165
+ removed is simply recreated.
166
+
167
+ Args:
168
+ cache_dir: The cache directory — see ``cache_stats()``.
169
+ older_than: If given, only remove entries written more than this many seconds ago
170
+ (the same age check ``cache_ttl`` uses), plus any corrupted entry, which the
171
+ cache can't serve anyway. ``None`` (the default) removes every entry.
172
+
173
+ Returns:
174
+ The number of entries removed and the bytes they took. A missing directory removes
175
+ nothing, ``CacheStats(entries=0, bytes=0)``, rather than raising.
176
+
177
+ Raises:
178
+ ValueError: If ``older_than`` is negative.
179
+ """
180
+ return DiskCache(cache_dir).clear(older_than=older_than)