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.
- libsocial_core-1.0.0/PKG-INFO +74 -0
- libsocial_core-1.0.0/README.md +48 -0
- libsocial_core-1.0.0/pyproject.toml +53 -0
- libsocial_core-1.0.0/pyproject.toml.orig +48 -0
- libsocial_core-1.0.0/src/libsocial/__init__.py +81 -0
- libsocial_core-1.0.0/src/libsocial/_http.py +45 -0
- libsocial_core-1.0.0/src/libsocial/cache.py +180 -0
- libsocial_core-1.0.0/src/libsocial/catalog.py +350 -0
- libsocial_core-1.0.0/src/libsocial/client.py +367 -0
- libsocial_core-1.0.0/src/libsocial/console.py +72 -0
- libsocial_core-1.0.0/src/libsocial/exceptions.py +240 -0
- libsocial_core-1.0.0/src/libsocial/models.py +224 -0
- libsocial_core-1.0.0/src/libsocial/numbering.py +34 -0
- libsocial_core-1.0.0/src/libsocial/pacing.py +68 -0
- libsocial_core-1.0.0/src/libsocial/py.typed +0 -0
- libsocial_core-1.0.0/src/libsocial/site.py +35 -0
- libsocial_core-1.0.0/src/libsocial/title.py +625 -0
|
@@ -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)
|