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.
Files changed (49) hide show
  1. sdvplot/__init__.py +68 -0
  2. sdvplot/_cache.py +484 -0
  3. sdvplot/_colors.py +206 -0
  4. sdvplot/_contrast.py +60 -0
  5. sdvplot/_court.py +110 -0
  6. sdvplot/_deprecate.py +39 -0
  7. sdvplot/_dispatch.py +342 -0
  8. sdvplot/_errors.py +93 -0
  9. sdvplot/_headshots.py +131 -0
  10. sdvplot/_images.py +292 -0
  11. sdvplot/_index.py +178 -0
  12. sdvplot/_lazy.py +15 -0
  13. sdvplot/_manifest.py +73 -0
  14. sdvplot/_marks.py +296 -0
  15. sdvplot/_normalize.py +82 -0
  16. sdvplot/_placement.py +168 -0
  17. sdvplot/_resolve.py +363 -0
  18. sdvplot/_surface.py +220 -0
  19. sdvplot/_tables.py +87 -0
  20. sdvplot/_tiers.py +135 -0
  21. sdvplot/_types.py +38 -0
  22. sdvplot/_versions.py +30 -0
  23. sdvplot/_web.py +61 -0
  24. sdvplot/altair.py +652 -0
  25. sdvplot/bokeh.py +323 -0
  26. sdvplot/data/INDEX_VERSION +1 -0
  27. sdvplot/data/aliases.parquet +0 -0
  28. sdvplot/data/teams.parquet +0 -0
  29. sdvplot/folium.py +316 -0
  30. sdvplot/great_tables/__init__.py +267 -0
  31. sdvplot/great_tables/_cells.py +1814 -0
  32. sdvplot/great_tables/_export.py +844 -0
  33. sdvplot/great_tables/_layout.py +1802 -0
  34. sdvplot/great_tables/_marks.py +798 -0
  35. sdvplot/great_tables/_themes.py +2134 -0
  36. sdvplot/holoviews.py +303 -0
  37. sdvplot/matplotlib.py +934 -0
  38. sdvplot/plotly.py +595 -0
  39. sdvplot/plotnine.py +1104 -0
  40. sdvplot/plottable.py +141 -0
  41. sdvplot/py.typed +0 -0
  42. sdvplot/pygal.py +416 -0
  43. sdvplot/reactable.py +488 -0
  44. sdvplot/testing.py +579 -0
  45. sdvplot/typing.py +21 -0
  46. sdvplot-0.1.0.dist-info/METADATA +188 -0
  47. sdvplot-0.1.0.dist-info/RECORD +49 -0
  48. sdvplot-0.1.0.dist-info/WHEEL +4 -0
  49. 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()