sdvplot 0.1.0__py3-none-any.whl
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.
- sdvplot/__init__.py +68 -0
- sdvplot/_cache.py +484 -0
- sdvplot/_colors.py +206 -0
- sdvplot/_contrast.py +60 -0
- sdvplot/_court.py +110 -0
- sdvplot/_deprecate.py +39 -0
- sdvplot/_dispatch.py +342 -0
- sdvplot/_errors.py +93 -0
- sdvplot/_headshots.py +131 -0
- sdvplot/_images.py +292 -0
- sdvplot/_index.py +178 -0
- sdvplot/_lazy.py +15 -0
- sdvplot/_manifest.py +73 -0
- sdvplot/_marks.py +296 -0
- sdvplot/_normalize.py +82 -0
- sdvplot/_placement.py +168 -0
- sdvplot/_resolve.py +363 -0
- sdvplot/_surface.py +220 -0
- sdvplot/_tables.py +87 -0
- sdvplot/_tiers.py +135 -0
- sdvplot/_types.py +38 -0
- sdvplot/_versions.py +30 -0
- sdvplot/_web.py +61 -0
- sdvplot/altair.py +652 -0
- sdvplot/bokeh.py +323 -0
- sdvplot/data/INDEX_VERSION +1 -0
- sdvplot/data/aliases.parquet +0 -0
- sdvplot/data/teams.parquet +0 -0
- sdvplot/folium.py +316 -0
- sdvplot/great_tables/__init__.py +267 -0
- sdvplot/great_tables/_cells.py +1814 -0
- sdvplot/great_tables/_export.py +844 -0
- sdvplot/great_tables/_layout.py +1802 -0
- sdvplot/great_tables/_marks.py +798 -0
- sdvplot/great_tables/_themes.py +2134 -0
- sdvplot/holoviews.py +303 -0
- sdvplot/matplotlib.py +934 -0
- sdvplot/plotly.py +595 -0
- sdvplot/plotnine.py +1104 -0
- sdvplot/plottable.py +141 -0
- sdvplot/py.typed +0 -0
- sdvplot/pygal.py +416 -0
- sdvplot/reactable.py +488 -0
- sdvplot/testing.py +579 -0
- sdvplot/typing.py +21 -0
- sdvplot-0.1.0.dist-info/METADATA +188 -0
- sdvplot-0.1.0.dist-info/RECORD +49 -0
- sdvplot-0.1.0.dist-info/WHEEL +4 -0
- sdvplot-0.1.0.dist-info/licenses/LICENSE +21 -0
sdvplot/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Team logos, wordmarks, headshots and colors for Python plots and tables (SportsDataverse)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _version
|
|
5
|
+
|
|
6
|
+
from sdvplot._cache import clear_cache
|
|
7
|
+
from sdvplot._colors import palette, team_colors
|
|
8
|
+
from sdvplot._court import court_coords
|
|
9
|
+
from sdvplot._dispatch import add_headshots, add_logos, add_wordmarks, axis_logos
|
|
10
|
+
from sdvplot._errors import (
|
|
11
|
+
DownloadError,
|
|
12
|
+
InputError,
|
|
13
|
+
IntegrityError,
|
|
14
|
+
OfflineError,
|
|
15
|
+
OptionalDependencyError,
|
|
16
|
+
SdvplotDeprecationWarning,
|
|
17
|
+
SdvplotError,
|
|
18
|
+
SdvplotWarning,
|
|
19
|
+
UnresolvedTeamError,
|
|
20
|
+
UnsafeCachePathError,
|
|
21
|
+
UnsafeDownloadError,
|
|
22
|
+
UnsupportedTargetError,
|
|
23
|
+
)
|
|
24
|
+
from sdvplot._headshots import headshot_url
|
|
25
|
+
from sdvplot._images import logo_image
|
|
26
|
+
from sdvplot._index import teams
|
|
27
|
+
from sdvplot._marks import logo_url, marks
|
|
28
|
+
from sdvplot._resolve import resolve, suggest
|
|
29
|
+
from sdvplot._surface import surface
|
|
30
|
+
from sdvplot._versions import versions
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
__version__ = _version("sdvplot")
|
|
34
|
+
except _PackageNotFoundError: # running from a source tree without installation
|
|
35
|
+
__version__ = "0.0.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DownloadError",
|
|
39
|
+
"InputError",
|
|
40
|
+
"IntegrityError",
|
|
41
|
+
"OfflineError",
|
|
42
|
+
"OptionalDependencyError",
|
|
43
|
+
"SdvplotDeprecationWarning",
|
|
44
|
+
"SdvplotError",
|
|
45
|
+
"SdvplotWarning",
|
|
46
|
+
"UnresolvedTeamError",
|
|
47
|
+
"UnsafeCachePathError",
|
|
48
|
+
"UnsafeDownloadError",
|
|
49
|
+
"UnsupportedTargetError",
|
|
50
|
+
"__version__",
|
|
51
|
+
"add_headshots",
|
|
52
|
+
"add_logos",
|
|
53
|
+
"add_wordmarks",
|
|
54
|
+
"axis_logos",
|
|
55
|
+
"clear_cache",
|
|
56
|
+
"court_coords",
|
|
57
|
+
"headshot_url",
|
|
58
|
+
"logo_image",
|
|
59
|
+
"logo_url",
|
|
60
|
+
"marks",
|
|
61
|
+
"palette",
|
|
62
|
+
"resolve",
|
|
63
|
+
"suggest",
|
|
64
|
+
"surface",
|
|
65
|
+
"team_colors",
|
|
66
|
+
"teams",
|
|
67
|
+
"versions",
|
|
68
|
+
]
|
sdvplot/_cache.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""The local cache: the logo manifest (refreshed by TTL + ETag) and logo images (content-addressed, immutable)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import socket
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import weakref
|
|
15
|
+
from collections.abc import Callable, Hashable, Iterator
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
from urllib.parse import urljoin, urlsplit
|
|
19
|
+
|
|
20
|
+
import platformdirs
|
|
21
|
+
|
|
22
|
+
from sdvplot._errors import (
|
|
23
|
+
DownloadError,
|
|
24
|
+
IntegrityError,
|
|
25
|
+
OfflineError,
|
|
26
|
+
UnsafeCachePathError,
|
|
27
|
+
UnsafeDownloadError,
|
|
28
|
+
warn,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
import requests
|
|
33
|
+
|
|
34
|
+
CACHE_ENV = "SDVPLOT_CACHE_DIR"
|
|
35
|
+
TTL_ENV = "SDVPLOT_CACHE_TTL"
|
|
36
|
+
DEFAULT_TTL_DAYS = 7.0
|
|
37
|
+
CACHE_SUBDIRS = ("manifest", "images", "rasters", "nflverse", "urlimages")
|
|
38
|
+
MARKER = ".sdvplot-cache" # written into a subdirectory sdvplot created; clear_cache only deletes marked ones
|
|
39
|
+
MAX_BYTES = 200 * 1024 * 1024 # default body cap, for the tables (the logo manifest is ~18 MB and grows)
|
|
40
|
+
IMAGE_MAX_BYTES = 25 * 1024 * 1024
|
|
41
|
+
DEADLINE_SECONDS = 120.0 # total wall-clock budget for one download (the (5, 60) timeout is per socket read)
|
|
42
|
+
MAX_REDIRECTS = 5
|
|
43
|
+
MEMORY_CACHES: list[Callable[[], object]] = [] # in-memory caches (decoded images) that clear_cache() empties
|
|
44
|
+
_warned: set[str] = set()
|
|
45
|
+
_intact: set[str] = set() # cached files already checked this process
|
|
46
|
+
_fresh: dict[tuple[str, str], tuple[Path, float]] = {} # (cache dir, relpath) -> (path, fetched_at): fetch_cached
|
|
47
|
+
_locks: weakref.WeakValueDictionary[Hashable, threading.Lock] = weakref.WeakValueDictionary()
|
|
48
|
+
_locks_guard = threading.Lock()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def key_lock(key: Hashable) -> threading.Lock:
|
|
52
|
+
"""The one lock for key while anyone holds it, so threads after the same file (or image) do the work once: the
|
|
53
|
+
rest wait, then find it done. Windows also refuses to replace a file another thread holds open."""
|
|
54
|
+
with _locks_guard:
|
|
55
|
+
return _locks.setdefault(key, threading.Lock())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _session() -> Any:
|
|
59
|
+
"""The shared requests Session, created on first use so ``import sdvplot`` does not import requests (~0.1 s)."""
|
|
60
|
+
session = globals().get("SESSION")
|
|
61
|
+
if session is None:
|
|
62
|
+
import requests
|
|
63
|
+
|
|
64
|
+
session = globals()["SESSION"] = requests.Session()
|
|
65
|
+
session.headers["User-Agent"] = "sdvplot (+https://github.com/sportsdataverse/sdvplot)"
|
|
66
|
+
adapter = _watched_adapter()
|
|
67
|
+
session.mount("https://", adapter)
|
|
68
|
+
session.mount("http://", adapter)
|
|
69
|
+
return session
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _watched_adapter() -> Any:
|
|
73
|
+
"""A requests adapter whose pools tell the current thread's ``_Watchdog`` which connection they hand out and every
|
|
74
|
+
socket they open, so it can shut a download down before there is a response (the TLS handshake, the headers)."""
|
|
75
|
+
from requests.adapters import HTTPAdapter
|
|
76
|
+
from urllib3 import HTTPConnectionPool, HTTPSConnectionPool
|
|
77
|
+
from urllib3.connection import HTTPConnection, HTTPSConnection
|
|
78
|
+
|
|
79
|
+
def watched(pool: Any, connection: Any) -> Any:
|
|
80
|
+
class Connection(connection):
|
|
81
|
+
def _new_conn(self) -> socket.socket:
|
|
82
|
+
sock = super()._new_conn()
|
|
83
|
+
if (dog := getattr(_watched, "dog", None)) is not None:
|
|
84
|
+
# a duplicate: wrapping the socket in TLS detaches the one urllib3 holds until the handshake ends
|
|
85
|
+
dog.socks.append(sock.dup())
|
|
86
|
+
return sock
|
|
87
|
+
|
|
88
|
+
class Pool(pool):
|
|
89
|
+
ConnectionCls = Connection
|
|
90
|
+
|
|
91
|
+
def _get_conn(self, timeout: float | None = None) -> Any:
|
|
92
|
+
conn = super()._get_conn(timeout)
|
|
93
|
+
if (dog := getattr(_watched, "dog", None)) is not None:
|
|
94
|
+
dog.conn = conn
|
|
95
|
+
return conn
|
|
96
|
+
|
|
97
|
+
return Pool
|
|
98
|
+
|
|
99
|
+
pools = {
|
|
100
|
+
"http": watched(HTTPConnectionPool, HTTPConnection),
|
|
101
|
+
"https": watched(HTTPSConnectionPool, HTTPSConnection),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# ponytail: a proxy's pools (proxy_manager_for) are not watched; through a proxy the deadline covers the body only
|
|
105
|
+
class Adapter(HTTPAdapter):
|
|
106
|
+
def init_poolmanager(self, *args: Any, **kwargs: Any) -> None:
|
|
107
|
+
super().init_poolmanager(*args, **kwargs)
|
|
108
|
+
self.poolmanager.pool_classes_by_scheme = pools
|
|
109
|
+
|
|
110
|
+
return Adapter()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def __getattr__(name: str) -> Any:
|
|
114
|
+
if name == "SESSION": # `_cache.SESSION` stays reachable (and patchable) although it is created lazily
|
|
115
|
+
return _session()
|
|
116
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cache_dir() -> Path:
|
|
120
|
+
return Path(os.environ.get(CACHE_ENV) or platformdirs.user_cache_dir("sdvplot"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cache_path(relpath: str) -> Path:
|
|
124
|
+
"""cache_dir() / relpath, refusing any relpath whose resolved location is outside the cache directory."""
|
|
125
|
+
path = (cache_dir().resolve() / relpath).resolve()
|
|
126
|
+
_check_contained(path, relpath)
|
|
127
|
+
return path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_contained(path: Path, label: object) -> None:
|
|
131
|
+
root = cache_dir().resolve()
|
|
132
|
+
if path == root or not path.is_relative_to(root):
|
|
133
|
+
raise UnsafeCachePathError(f"cache path {label!r} is not inside the cache directory")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ttl_seconds() -> float:
|
|
137
|
+
raw = os.environ.get(TTL_ENV)
|
|
138
|
+
return (float(raw) if raw else DEFAULT_TTL_DAYS) * 86400
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _meta_path(path: Path) -> Path:
|
|
142
|
+
return path.with_name(path.name + ".meta.json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_meta(relpath: str) -> dict | None:
|
|
146
|
+
meta = _meta_path(cache_path(relpath))
|
|
147
|
+
if not meta.exists():
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(meta.read_text())
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _mark_new_subdir(path: Path) -> None:
|
|
156
|
+
"""When this write creates one of the cache subdirectories, mark it as ours (an existing folder is never marked)."""
|
|
157
|
+
try:
|
|
158
|
+
root = cache_dir().resolve()
|
|
159
|
+
sub = path.resolve().relative_to(root).parts[0]
|
|
160
|
+
except (ValueError, IndexError):
|
|
161
|
+
return
|
|
162
|
+
subdir = root / sub
|
|
163
|
+
if sub in CACHE_SUBDIRS and not subdir.exists():
|
|
164
|
+
subdir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
with contextlib.suppress(OSError):
|
|
166
|
+
(subdir / MARKER).write_bytes(b"")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def atomic_write(path: Path, data: bytes) -> None:
|
|
170
|
+
"""Write to a temp file beside the target, then rename: a reader never sees a partial file."""
|
|
171
|
+
_check_contained(path.resolve(), str(path))
|
|
172
|
+
_mark_new_subdir(path)
|
|
173
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
tmp = path.with_name(f"{path.name}.{os.urandom(8).hex()}.part")
|
|
175
|
+
# mode 0o666 with the kernel applying the umask: world-readable like any file, and the process umask is never
|
|
176
|
+
# read or changed (toggling it races between threads)
|
|
177
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666)
|
|
178
|
+
try:
|
|
179
|
+
with os.fdopen(fd, "wb") as f:
|
|
180
|
+
f.write(data)
|
|
181
|
+
os.replace(tmp, path)
|
|
182
|
+
except BaseException:
|
|
183
|
+
tmp.unlink(missing_ok=True)
|
|
184
|
+
raise
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _warn_once(key: str, message: str) -> None:
|
|
188
|
+
if key not in _warned:
|
|
189
|
+
_warned.add(key)
|
|
190
|
+
warn(message)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _offline_message(url: str) -> str:
|
|
194
|
+
return (
|
|
195
|
+
f"could not download {url} and there is no cached copy; connect once, or point {CACHE_ENV} "
|
|
196
|
+
"at a directory that has one"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _check_https(url: str) -> None:
|
|
201
|
+
if urlsplit(url).scheme != "https":
|
|
202
|
+
raise UnsafeDownloadError(f"refusing to download {url}: not https")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# A plain https URL: a host, then only RFC 3986 characters, minus ' (RFC 3986 allows it, but it ends a single-quoted
|
|
206
|
+
# HTML attribute). No quote, <, >, whitespace, backslash or control character, so a URL from the logo manifest or
|
|
207
|
+
# nflverse's player table cannot break out of the HTML attribute or <script> block a web adapter writes it into.
|
|
208
|
+
# A pattern both re and polars (Rust regex) read the same way.
|
|
209
|
+
SAFE_URL = r"https://[A-Za-z0-9.\-]+(?::[0-9]+)?(?:[/?#][A-Za-z0-9\-._~:/?#\[\]@!$&()*+,;=%]*)?"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def safe_url(url: object) -> bool:
|
|
213
|
+
"""True when ``url`` is a plain https URL (``SAFE_URL``)."""
|
|
214
|
+
return isinstance(url, str) and re.fullmatch(SAFE_URL, url) is not None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _pieces(r: requests.Response) -> Iterator[bytes]:
|
|
218
|
+
"""Body pieces as they arrive, so the cap and the deadline are checked on every receive (not per full chunk: a
|
|
219
|
+
server dripping a byte at a time would otherwise stall a 64 KiB read for hours). urllib3 >= 2.6 (a dependency floor)
|
|
220
|
+
returns whatever has arrived, and decompresses at most the amount asked for."""
|
|
221
|
+
while chunk := r.raw.read1(65536, decode_content=True):
|
|
222
|
+
yield chunk
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
_watched = threading.local() # .dog: the _Watchdog of the download running on this thread
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _Watchdog:
|
|
229
|
+
"""Shuts a download's sockets down at its deadline. The (5, 60) timeout bounds each socket read, not the call: a
|
|
230
|
+
server sending a byte every 59 s held the TLS handshake or the headers open indefinitely, and the body loop's own
|
|
231
|
+
deadline check only runs between reads. One timer covers the handshake, the headers and the body of every hop."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, seconds: float) -> None:
|
|
234
|
+
self.conn: Any = None # the pooled connection in use (a reused one opens no socket)
|
|
235
|
+
self.socks: list[socket.socket] = [] # duplicates of the sockets this download opened
|
|
236
|
+
self.response: Any = None
|
|
237
|
+
self.expired = self.stopped = False
|
|
238
|
+
self._start(seconds)
|
|
239
|
+
|
|
240
|
+
def _start(self, seconds: float) -> None:
|
|
241
|
+
self._timer = threading.Timer(seconds, self._fire)
|
|
242
|
+
self._timer.daemon = True
|
|
243
|
+
self._timer.start()
|
|
244
|
+
|
|
245
|
+
def _fire(self) -> None:
|
|
246
|
+
if self.stopped:
|
|
247
|
+
return
|
|
248
|
+
self.expired = True
|
|
249
|
+
# each attempt may meet a socket already closed, or none yet: that is fine, the next tick tries again
|
|
250
|
+
for sock in (*self.socks, getattr(self.conn, "sock", None)):
|
|
251
|
+
with contextlib.suppress(Exception):
|
|
252
|
+
if sock is not None: # the plain socket's method: it wakes a read blocked inside TLS too
|
|
253
|
+
socket.socket.shutdown(sock, socket.SHUT_RDWR)
|
|
254
|
+
with contextlib.suppress(Exception):
|
|
255
|
+
self.response.raw.shutdown() # urllib3's: the response owns a socket the connection let go of
|
|
256
|
+
self._start(0.1) # until stop(): a reconnect or the next hop is shut down too
|
|
257
|
+
|
|
258
|
+
def stop(self) -> None:
|
|
259
|
+
self.stopped = True
|
|
260
|
+
self._timer.cancel()
|
|
261
|
+
for sock in self.socks:
|
|
262
|
+
sock.close()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _download(url: str, headers: dict | None, max_bytes: int) -> tuple[requests.Response, bytes]:
|
|
266
|
+
"""GET url with a byte cap, a total deadline and https-only redirects (followed by hand, so a hop to http is never
|
|
267
|
+
requested). Raises UnsafeDownloadError when refused or past the deadline; requests errors propagate."""
|
|
268
|
+
deadline = time.monotonic() + DEADLINE_SECONDS
|
|
269
|
+
dog = _watched.dog = _Watchdog(DEADLINE_SECONDS)
|
|
270
|
+
try:
|
|
271
|
+
for _ in range(MAX_REDIRECTS + 1):
|
|
272
|
+
_check_https(url)
|
|
273
|
+
left = deadline - time.monotonic()
|
|
274
|
+
if left <= 0:
|
|
275
|
+
raise UnsafeDownloadError(f"{url}: download exceeded {DEADLINE_SECONDS:.0f} s")
|
|
276
|
+
# no read waits past the deadline either: on Windows a shut socket does not wake a read already waiting
|
|
277
|
+
timeout = (min(5, left), min(60, left))
|
|
278
|
+
r = dog.response = _session().get(url, headers=headers, timeout=timeout, stream=True, allow_redirects=False)
|
|
279
|
+
try:
|
|
280
|
+
if r.status_code in (301, 302, 303, 307, 308) and r.headers.get("Location"):
|
|
281
|
+
url = urljoin(url, r.headers["Location"])
|
|
282
|
+
continue
|
|
283
|
+
declared = r.headers.get("Content-Length")
|
|
284
|
+
if declared is not None and declared.isdigit() and int(declared) > max_bytes:
|
|
285
|
+
raise UnsafeDownloadError(f"{url}: {declared} bytes exceeds the {max_bytes} byte limit")
|
|
286
|
+
if r.status_code >= 400 or r.status_code == 304:
|
|
287
|
+
return r, b""
|
|
288
|
+
chunks, size = [], 0
|
|
289
|
+
for chunk in _pieces(r):
|
|
290
|
+
size += len(chunk)
|
|
291
|
+
if size > max_bytes:
|
|
292
|
+
raise UnsafeDownloadError(f"{url}: body exceeds the {max_bytes} byte limit")
|
|
293
|
+
if time.monotonic() > deadline:
|
|
294
|
+
raise UnsafeDownloadError(f"{url}: download exceeded {DEADLINE_SECONDS:.0f} s")
|
|
295
|
+
chunks.append(chunk)
|
|
296
|
+
if dog.expired: # a shut socket can end a body early without an error
|
|
297
|
+
raise UnsafeDownloadError(f"{url}: download exceeded {DEADLINE_SECONDS:.0f} s")
|
|
298
|
+
return r, b"".join(chunks)
|
|
299
|
+
finally:
|
|
300
|
+
r.close()
|
|
301
|
+
raise UnsafeDownloadError(f"{url}: more than {MAX_REDIRECTS} redirects")
|
|
302
|
+
except Exception as e:
|
|
303
|
+
# a read that timed out at the deadline can raise before the watchdog fires: both are the deadline
|
|
304
|
+
if isinstance(e, UnsafeDownloadError) or not (dog.expired or time.monotonic() >= deadline):
|
|
305
|
+
raise
|
|
306
|
+
raise UnsafeDownloadError(f"{url}: download exceeded {DEADLINE_SECONDS:.0f} s") from e
|
|
307
|
+
finally:
|
|
308
|
+
dog.stop()
|
|
309
|
+
_watched.dog = None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _heal(path: Path, validate: Callable[[bytes], object] | None, sha256: str | None = None) -> bool:
|
|
313
|
+
"""True when the cached file is intact (checked once per process); a corrupt or empty one is deleted."""
|
|
314
|
+
key = str(path)
|
|
315
|
+
if key in _intact:
|
|
316
|
+
return True
|
|
317
|
+
data = path.read_bytes() # a read error (permissions, a transient fault) is not corruption: it propagates
|
|
318
|
+
try:
|
|
319
|
+
if not data:
|
|
320
|
+
raise ValueError("empty file")
|
|
321
|
+
if sha256 is not None and hashlib.sha256(data).hexdigest() != sha256:
|
|
322
|
+
raise ValueError("sha256 mismatch")
|
|
323
|
+
if validate is not None:
|
|
324
|
+
validate(data)
|
|
325
|
+
except Exception: # a failed integrity check (sha, parse, empty): the cached copy cannot be trusted
|
|
326
|
+
path.unlink() # if this fails the error propagates: never hand back the corrupt file
|
|
327
|
+
_meta_path(path).unlink(missing_ok=True)
|
|
328
|
+
return False
|
|
329
|
+
_intact.add(key)
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def fetch_cached(
|
|
334
|
+
url: str, relpath: str, *, validate: Callable[[bytes], object] | None = None, max_bytes: int = MAX_BYTES
|
|
335
|
+
) -> Path:
|
|
336
|
+
"""``_fetch_cached``, with a file this process found fresh remembered until its TTL runs out: a warm call is a dict
|
|
337
|
+
lookup and one stat, not two path resolutions and a JSON read. It is trusted only while ``_intact`` vouches for the
|
|
338
|
+
file (``clear_cache()`` empties it) and the file still exists (another process may have cleared the cache). Threads
|
|
339
|
+
after the same file share one refresh: the others wait, then find it fresh."""
|
|
340
|
+
key = (str(cache_dir()), relpath)
|
|
341
|
+
hit = _fresh.get(key)
|
|
342
|
+
if hit is not None and str(hit[0]) in _intact and hit[0].exists() and time.time() - hit[1] < ttl_seconds():
|
|
343
|
+
return hit[0]
|
|
344
|
+
with key_lock(str(cache_path(relpath))):
|
|
345
|
+
path = _fetch_cached(url, relpath, validate=validate, max_bytes=max_bytes)
|
|
346
|
+
fetched_at = (read_meta(relpath) or {}).get("fetched_at", 0)
|
|
347
|
+
if str(path) in _intact and time.time() - fetched_at < ttl_seconds(): # not a stale copy kept after a failure
|
|
348
|
+
_fresh[key] = (path, fetched_at)
|
|
349
|
+
return path
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _fetch_cached(
|
|
353
|
+
url: str, relpath: str, *, validate: Callable[[bytes], object] | None = None, max_bytes: int = MAX_BYTES
|
|
354
|
+
) -> Path:
|
|
355
|
+
"""A cached copy of url, refreshed when older than the TTL (a 304 just renews it). On any failure (network,
|
|
356
|
+
truncation, a validate() rejection) the previous copy is kept and used with one warning."""
|
|
357
|
+
path = cache_path(relpath)
|
|
358
|
+
if path.exists() and not _heal(path, validate):
|
|
359
|
+
pass # a corrupt cached file was deleted: fall through to a fresh download
|
|
360
|
+
meta = read_meta(relpath) or {}
|
|
361
|
+
if path.exists() and time.time() - meta.get("fetched_at", 0) < ttl_seconds():
|
|
362
|
+
return path
|
|
363
|
+
if url in _warned and path.exists():
|
|
364
|
+
return path
|
|
365
|
+
headers = {"If-None-Match": meta["etag"]} if path.exists() and meta.get("etag") else {}
|
|
366
|
+
import requests # lazy: only a download needs it
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
r, body = _download(url, headers, max_bytes)
|
|
370
|
+
if r.status_code == 304 and path.exists():
|
|
371
|
+
# a failed meta write lands in the except below, which keeps the cached copy with one warning
|
|
372
|
+
atomic_write(_meta_path(path), json.dumps({**meta, "fetched_at": time.time()}).encode())
|
|
373
|
+
return path
|
|
374
|
+
r.raise_for_status()
|
|
375
|
+
declared = r.headers.get("Content-Length")
|
|
376
|
+
if (
|
|
377
|
+
declared is not None
|
|
378
|
+
and r.headers.get("Content-Encoding") in (None, "identity")
|
|
379
|
+
and int(declared) != len(body)
|
|
380
|
+
):
|
|
381
|
+
raise OSError(f"truncated download: {len(body)} of {declared} bytes")
|
|
382
|
+
if validate is not None:
|
|
383
|
+
try:
|
|
384
|
+
validate(body)
|
|
385
|
+
except Exception as e: # any validator error (a polars parse error too) rejects the body: never cache it
|
|
386
|
+
raise ValueError(f"{type(e).__name__}: {e}") from e
|
|
387
|
+
except (requests.RequestException, OSError, ValueError) as e:
|
|
388
|
+
if path.exists():
|
|
389
|
+
_warn_once(url, f"could not refresh {url} ({e}); using the cached copy")
|
|
390
|
+
return path
|
|
391
|
+
if isinstance(e, UnsafeDownloadError):
|
|
392
|
+
raise
|
|
393
|
+
error = DownloadError if isinstance(e, requests.HTTPError) else OfflineError
|
|
394
|
+
raise error(f"{_offline_message(url)} ({e})") from e
|
|
395
|
+
try:
|
|
396
|
+
atomic_write(path, body)
|
|
397
|
+
new_meta = {
|
|
398
|
+
"etag": r.headers.get("ETag"),
|
|
399
|
+
"last_modified": r.headers.get("Last-Modified"),
|
|
400
|
+
"fetched_at": time.time(),
|
|
401
|
+
}
|
|
402
|
+
atomic_write(_meta_path(path), json.dumps(new_meta).encode())
|
|
403
|
+
_intact.add(str(path))
|
|
404
|
+
except (OSError, ValueError) as e:
|
|
405
|
+
if path.exists():
|
|
406
|
+
_warn_once(url, f"could not refresh {url} ({e}); using the cached copy")
|
|
407
|
+
return path
|
|
408
|
+
raise OfflineError(f"{_offline_message(url)} ({e})") from e
|
|
409
|
+
return path
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def fetch_immutable(url: str, relpath: str, sha256: str, *, max_bytes: int = IMAGE_MAX_BYTES) -> Path:
|
|
413
|
+
"""A content-addressed file: downloaded once, checked against its sha256, never refreshed. A cached copy that no
|
|
414
|
+
longer matches its sha256 (corrupt, truncated) is deleted and downloaded again. Threads after the same file share
|
|
415
|
+
one download."""
|
|
416
|
+
path = cache_path(relpath)
|
|
417
|
+
with key_lock(str(path)):
|
|
418
|
+
if path.exists() and _heal(path, None, sha256):
|
|
419
|
+
return path
|
|
420
|
+
import requests # lazy: only a download needs it
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
r, body = _download(url, None, max_bytes)
|
|
424
|
+
r.raise_for_status()
|
|
425
|
+
except requests.HTTPError as e: # a 4xx or 5xx: sdvplot's error, still an OSError as requests' was
|
|
426
|
+
raise DownloadError(f"{_offline_message(url)} ({e})") from e
|
|
427
|
+
except requests.RequestException as e:
|
|
428
|
+
raise OfflineError(f"{_offline_message(url)} ({e})") from e
|
|
429
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
430
|
+
if digest != sha256:
|
|
431
|
+
raise IntegrityError(f"{url}: sha256 {digest} does not match the manifest ({sha256}); not cached")
|
|
432
|
+
try:
|
|
433
|
+
atomic_write(path, body)
|
|
434
|
+
except PermissionError:
|
|
435
|
+
# Windows refuses to replace a file another process holds open; when that process wrote the same content
|
|
436
|
+
# (it is content-addressed), the file is already what this call wanted. atomic_write dropped the .part.
|
|
437
|
+
if not (path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == sha256):
|
|
438
|
+
raise
|
|
439
|
+
_intact.add(str(path))
|
|
440
|
+
return path
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def clear_cache() -> None:
|
|
444
|
+
"""Delete everything sdvplot has cached (manifest, images, rasters, nflverse, URL images such as headshots).
|
|
445
|
+
|
|
446
|
+
The next call that needs a mark downloads it again. The cache directory is ``SDVPLOT_CACHE_DIR`` when set. In the
|
|
447
|
+
default cache directory every subdirectory above is removed. In a directory you chose with ``SDVPLOT_CACHE_DIR``
|
|
448
|
+
only subdirectories sdvplot created (they hold a ``.sdvplot-cache`` marker) are removed; a folder of your own with
|
|
449
|
+
the same name is left alone with a warning that names it. A subdirectory that is a symlink is unlinked in the
|
|
450
|
+
default directory (what it points to is untouched) and left alone with a warning in one you chose.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
None: Nothing; the cache subdirectories are removed.
|
|
454
|
+
|
|
455
|
+
Example:
|
|
456
|
+
::
|
|
457
|
+
|
|
458
|
+
import sdvplot
|
|
459
|
+
|
|
460
|
+
sdvplot.clear_cache() # the next logo_url() / logo_image() re-downloads
|
|
461
|
+
|
|
462
|
+
See Also:
|
|
463
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
464
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
465
|
+
"""
|
|
466
|
+
root = cache_dir()
|
|
467
|
+
owned = not os.environ.get(CACHE_ENV) # the default directory is sdvplot's own; a custom one may hold other things
|
|
468
|
+
try:
|
|
469
|
+
for subdir in CACHE_SUBDIRS:
|
|
470
|
+
path = root / subdir
|
|
471
|
+
if path.is_symlink(): # sdvplot never makes one, and cache_path() refuses one that leads out of the cache
|
|
472
|
+
if owned:
|
|
473
|
+
path.unlink() # the link only, never what it points to
|
|
474
|
+
else:
|
|
475
|
+
warn(f"{path} is a symlink sdvplot did not create, so it was left in place; delete it by hand")
|
|
476
|
+
elif path.exists() and (owned or (path / MARKER).exists()):
|
|
477
|
+
shutil.rmtree(path)
|
|
478
|
+
elif path.exists():
|
|
479
|
+
warn(f"{path} was not created by sdvplot, so it was left in place; delete it by hand if it is a cache")
|
|
480
|
+
finally: # a failed removal still empties the in-memory caches
|
|
481
|
+
_warned.clear()
|
|
482
|
+
_intact.clear()
|
|
483
|
+
for clear in MEMORY_CACHES:
|
|
484
|
+
clear()
|