ucdinfo 0.2__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.
ucdinfo/cache.py ADDED
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/python3
2
+
3
+ """cache
4
+
5
+ A content-agnostic manager for a versioned, pickled data cache with an
6
+ offline-safe update path. Each cacheable class constructs a CacheManager,
7
+ supplying its own name, format version, remote URL and a builder callback
8
+ that fetches+parses a fresh instance. The manager handles path resolution,
9
+ freshness checks, versioned filenames, cleanup of stale-format files, and
10
+ load/save; it never inspects what it is pickling.
11
+
12
+ from ucd.cache import CacheManager
13
+
14
+ cache = CacheManager(
15
+ name="ucdata",
16
+ format_version=1,
17
+ remote_url="http://www.unicode.org/Public/latest/ucdxml/ucd.all.flat.zip",
18
+ builder=UCD.build_from_remote, # callable() -> pickleable instance
19
+ package="ucd", # for importlib.resources bundle lookup
20
+ )
21
+ obj = cache.load() # fresh check -> cache -> bundle -> remote
22
+ """
23
+
24
+ import os, sys, bz2, pickle, glob, re
25
+ import urllib.request, urllib.error
26
+ from datetime import datetime, timezone, timedelta
27
+ from email.utils import parsedate_to_datetime
28
+ import platformdirs
29
+
30
+
31
+ def _userroot():
32
+ """True if running as an administrative/root user (writable system cache)."""
33
+ if sys.platform == 'win32':
34
+ import ctypes
35
+ try:
36
+ return ctypes.windll.shell32.IsUserAdmin() != 0
37
+ except Exception:
38
+ return False
39
+ else:
40
+ return os.geteuid() == 0
41
+
42
+
43
+ def _varcache():
44
+ """System-wide cache directory for privileged installs."""
45
+ if sys.platform == "win32":
46
+ return os.path.join(os.environ.get("ProgramData", 'C:\\ProgramData'),
47
+ "python_ucd", "Cache")
48
+ elif sys.platform == "darwin":
49
+ return "/Library/Caches/python_ucd"
50
+ else:
51
+ return "/var/cache/python_ucd"
52
+
53
+
54
+ class CacheManager:
55
+ def __init__(self, name, format_version, remote_url, builder,
56
+ cache_period=30, package=None, subdir="python_ucd"):
57
+ """
58
+ name basename for the cache file, e.g. "ucdata"
59
+ format_version integer owned by the content class; changes the filename
60
+ remote_url where build_from_remote's data comes from (for HEAD/GET)
61
+ builder callable() returning a fresh, pickleable instance
62
+ cache_period days before a HEAD freshness check is made
63
+ package importlib.resources package holding bundled data, or None
64
+ subdir cache subdirectory name under the platform cache root
65
+ """
66
+ self.name = name
67
+ self.format_version = format_version
68
+ self.remote_url = remote_url
69
+ self.builder = builder
70
+ self.cache_period = cache_period
71
+ self.package = package
72
+ self.subdir = subdir
73
+
74
+ # ---- naming -----------------------------------------------------------
75
+
76
+ def filename(self):
77
+ return f"{self.name}_v{self.format_version}.pickle.bz2"
78
+
79
+ def _offline(self):
80
+ return getattr(sys, "frozen", False)
81
+
82
+ # ---- path resolution --------------------------------------------------
83
+
84
+ def _cache_dirs(self):
85
+ dirs = []
86
+ if _userroot():
87
+ dirs.append(_varcache())
88
+ dirs.append(platformdirs.user_cache_dir(self.subdir))
89
+ if not _userroot():
90
+ dirs.append("/var/cache/" + self.subdir) # readable system cache
91
+ return dirs
92
+
93
+ def cache_path(self):
94
+ """Return (read_path_or_None, write_path). read_path is the first
95
+ existing cache file of the current format across candidate dirs;
96
+ write_path is where we would save a fresh one."""
97
+ fname = self.filename()
98
+ dirs = self._cache_dirs()
99
+
100
+ read_path = None
101
+ for d in dirs:
102
+ p = os.path.join(d, fname)
103
+ if os.path.exists(p):
104
+ read_path = p
105
+ break
106
+
107
+ write_dir = dirs[0]
108
+ try:
109
+ os.makedirs(write_dir, exist_ok=True)
110
+ except OSError:
111
+ pass
112
+ return read_path, os.path.join(write_dir, fname)
113
+
114
+ def _bundled_path(self):
115
+ if not self.package:
116
+ return None
117
+ try:
118
+ from importlib.resources import files
119
+ p = files(self.package) / "data" / self.filename()
120
+ return str(p) if p.is_file() else None
121
+ except (ModuleNotFoundError, FileNotFoundError, TypeError):
122
+ return None
123
+
124
+ # ---- freshness --------------------------------------------------------
125
+
126
+ def test_update(self):
127
+ """True if the cache should be refreshed. Younger than cache_period ->
128
+ assume current (no network). Otherwise HEAD the remote; if not newer,
129
+ touch the cache mtime to reset the clock and return False."""
130
+ if self._offline():
131
+ return False
132
+ cache_path, _ = self.cache_path()
133
+ if cache_path is None or not os.path.exists(cache_path):
134
+ return True
135
+
136
+ mtime = datetime.fromtimestamp(os.path.getmtime(cache_path),
137
+ tz=timezone.utc)
138
+ if datetime.now(timezone.utc) - mtime < timedelta(days=self.cache_period):
139
+ return False
140
+
141
+ req = urllib.request.Request(self.remote_url, method="HEAD")
142
+ try:
143
+ with urllib.request.urlopen(req) as resp:
144
+ remote_lm = resp.headers.get("Last-Modified")
145
+ except urllib.error.URLError:
146
+ return False
147
+
148
+ if remote_lm and parsedate_to_datetime(remote_lm) > mtime:
149
+ return True
150
+
151
+ try:
152
+ os.utime(cache_path, None)
153
+ except PermissionError:
154
+ pass
155
+ return False
156
+
157
+ # ---- fetch / save / prune --------------------------------------------
158
+
159
+ def force_update(self):
160
+ """Build a fresh instance from remote, save to cache, prune stale
161
+ formats. Save failures (read-only dir) are non-fatal; the built
162
+ object is returned regardless."""
163
+ obj = self.builder()
164
+ _, write_path = self.cache_path()
165
+ try:
166
+ self.save(obj, write_path)
167
+ self._cleanup_old_caches(os.path.dirname(write_path))
168
+ except OSError:
169
+ pass
170
+ return obj
171
+
172
+ def save(self, obj, localfile):
173
+ with bz2.open(localfile, "wb") as outf:
174
+ pickle.dump(obj, outf, protocol=4) # 3.4+ floor for cross-version load
175
+
176
+ def _load_file(self, localfile):
177
+ with bz2.open(localfile, "rb") as inf:
178
+ return pickle.load(inf)
179
+
180
+ def _cleanup_old_caches(self, cache_dir):
181
+ """Best-effort removal of stale-format cache files we own."""
182
+ keep = self.filename()
183
+ pattern = os.path.join(cache_dir, f"{re.escape(self.name)}_v*.pickle.bz2")
184
+ exact = re.compile(rf"{re.escape(self.name)}_v\d+\.pickle\.bz2\Z")
185
+ for path in glob.glob(pattern):
186
+ base = os.path.basename(path)
187
+ if base == keep or not exact.match(base):
188
+ continue
189
+ try:
190
+ os.remove(path)
191
+ except OSError:
192
+ pass
193
+
194
+ # ---- top-level load ---------------------------------------------------
195
+
196
+ def load(self, localfile=None):
197
+ """Resolve and load an instance: explicit file, else fresh-if-stale
198
+ remote, else existing cache, else bundled data, else (if online) a
199
+ remote build. Returns the loaded object, or None if nothing is
200
+ available (caller decides how to represent an empty instance)."""
201
+ if localfile is not None:
202
+ return self._load_file(localfile) if os.path.exists(localfile) else None
203
+
204
+ if not self._offline() and self.test_update():
205
+ try:
206
+ return self.force_update()
207
+ except (urllib.error.URLError, OSError):
208
+ pass
209
+
210
+ read_path, _ = self.cache_path()
211
+ path = read_path or self._bundled_path()
212
+ if path is not None:
213
+ try:
214
+ return self._load_file(path)
215
+ except (OSError, pickle.UnpickleError):
216
+ pass
217
+
218
+ if not self._offline():
219
+ try:
220
+ return self.force_update()
221
+ except (urllib.error.URLError, OSError):
222
+ pass
223
+ return None