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.
- ucd/data/ucdata_pickle_0.2.bz2 +0 -0
- ucdinfo/__init__.py +4 -0
- ucdinfo/_initold.py +568 -0
- ucdinfo/cache.py +223 -0
- ucdinfo/ucd.py +580 -0
- ucdinfo-0.2.dist-info/METADATA +82 -0
- ucdinfo-0.2.dist-info/RECORD +11 -0
- ucdinfo-0.2.dist-info/WHEEL +5 -0
- ucdinfo-0.2.dist-info/entry_points.txt +2 -0
- ucdinfo-0.2.dist-info/licenses/LICENSE +21 -0
- ucdinfo-0.2.dist-info/top_level.txt +1 -0
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
|