implanet 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.
- implanet/__init__.py +66 -0
- implanet/assets/__init__.py +359 -0
- implanet/assets/_cache.py +182 -0
- implanet/assets/_registry.py +115 -0
- implanet/assets/_synthetic.py +83 -0
- implanet/assets/data/kernels.json +194 -0
- implanet/assets/data/manifest.json +713 -0
- implanet/ephemeris.py +266 -0
- implanet/fetch.py +256 -0
- implanet/overlays.py +301 -0
- implanet/projection.py +153 -0
- implanet/render.py +467 -0
- implanet-0.1.0.dist-info/METADATA +1035 -0
- implanet-0.1.0.dist-info/RECORD +18 -0
- implanet-0.1.0.dist-info/WHEEL +5 -0
- implanet-0.1.0.dist-info/entry_points.txt +2 -0
- implanet-0.1.0.dist-info/licenses/LICENSE +21 -0
- implanet-0.1.0.dist-info/top_level.txt +1 -0
implanet/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Render a planet's equirectangular map into a 2D view from a given direction."""
|
|
2
|
+
|
|
3
|
+
from implanet.render import render_disk, render_flatmap, render_info
|
|
4
|
+
from implanet.projection import camera_basis, orthographic_rays, sphere_to_uv
|
|
5
|
+
from implanet.overlays import (
|
|
6
|
+
flatmap_terminator,
|
|
7
|
+
graticule_segments,
|
|
8
|
+
limb_circle,
|
|
9
|
+
subobserver_point,
|
|
10
|
+
disk_terminator,
|
|
11
|
+
)
|
|
12
|
+
from implanet.assets import (
|
|
13
|
+
attribution,
|
|
14
|
+
get_texture,
|
|
15
|
+
kernel_license_notes,
|
|
16
|
+
list_maps,
|
|
17
|
+
show_attribution,
|
|
18
|
+
show_maps,
|
|
19
|
+
texture_license_notes,
|
|
20
|
+
)
|
|
21
|
+
from implanet.fetch import download_maps
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"render_disk",
|
|
25
|
+
"render_flatmap",
|
|
26
|
+
"render_info",
|
|
27
|
+
"camera_basis",
|
|
28
|
+
"orthographic_rays",
|
|
29
|
+
"sphere_to_uv",
|
|
30
|
+
"graticule_segments",
|
|
31
|
+
"limb_circle",
|
|
32
|
+
"subobserver_point",
|
|
33
|
+
"disk_terminator",
|
|
34
|
+
"flatmap_terminator",
|
|
35
|
+
"get_texture",
|
|
36
|
+
"download_maps",
|
|
37
|
+
"list_maps",
|
|
38
|
+
"show_maps",
|
|
39
|
+
"attribution",
|
|
40
|
+
"show_attribution",
|
|
41
|
+
"texture_license_notes",
|
|
42
|
+
"kernel_license_notes",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# Ephemeris support requires the optional `spiceypy` dependency. Import
|
|
46
|
+
# lazily so the rest of the package keeps working without it.
|
|
47
|
+
try:
|
|
48
|
+
from implanet.ephemeris import (
|
|
49
|
+
sun_direction,
|
|
50
|
+
sub_solar_point,
|
|
51
|
+
view_direction_from_earth,
|
|
52
|
+
ensure_kernels,
|
|
53
|
+
load_kernels,
|
|
54
|
+
known_bodies as known_ephemeris_bodies,
|
|
55
|
+
)
|
|
56
|
+
__all__ += [
|
|
57
|
+
"sun_direction",
|
|
58
|
+
"sub_solar_point",
|
|
59
|
+
"view_direction_from_earth",
|
|
60
|
+
"ensure_kernels",
|
|
61
|
+
"load_kernels",
|
|
62
|
+
"known_ephemeris_bodies",
|
|
63
|
+
]
|
|
64
|
+
except ImportError:
|
|
65
|
+
pass
|
|
66
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Lazy, cached access to implanet's external assets.
|
|
2
|
+
|
|
3
|
+
Two asset families, two registries:
|
|
4
|
+
|
|
5
|
+
* **textures** — equirectangular body maps, catalogued in
|
|
6
|
+
``maps/manifest.json``.
|
|
7
|
+
* **kernels** — NAIF SPICE kernels, catalogued in ``maps/kernels.json``.
|
|
8
|
+
|
|
9
|
+
Nothing is bundled in the wheel; assets are downloaded on first use into
|
|
10
|
+
a user cache (override with ``IMPLANET_CACHE``; in a dev checkout the
|
|
11
|
+
repo's ``maps/data`` and ``kernels`` dirs are reused automatically). See
|
|
12
|
+
``implanet.assets._cache`` for the full resolution order.
|
|
13
|
+
|
|
14
|
+
Typical use::
|
|
15
|
+
|
|
16
|
+
from implanet.assets import get_texture, get_kernel
|
|
17
|
+
tex = get_texture("Mars") # -> Path, downloads if needed
|
|
18
|
+
spk = get_kernel("voyager2_neptune") # -> Path, downloads if needed
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from implanet.assets._cache import (
|
|
27
|
+
_human,
|
|
28
|
+
cache_base,
|
|
29
|
+
download,
|
|
30
|
+
kernels_dir,
|
|
31
|
+
maps_dir,
|
|
32
|
+
)
|
|
33
|
+
from implanet.assets._registry import (
|
|
34
|
+
find_kernel,
|
|
35
|
+
find_texture,
|
|
36
|
+
kernel_entries,
|
|
37
|
+
kernel_license_notes,
|
|
38
|
+
kernel_registry,
|
|
39
|
+
texture_entries,
|
|
40
|
+
texture_license_notes,
|
|
41
|
+
texture_registry,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"get_texture",
|
|
46
|
+
"get_kernel",
|
|
47
|
+
"texture_path",
|
|
48
|
+
"kernel_path",
|
|
49
|
+
"cache_base",
|
|
50
|
+
"kernels_dir",
|
|
51
|
+
"maps_dir",
|
|
52
|
+
"texture_registry",
|
|
53
|
+
"kernel_registry",
|
|
54
|
+
"texture_entries",
|
|
55
|
+
"kernel_entries",
|
|
56
|
+
"find_texture",
|
|
57
|
+
"find_kernel",
|
|
58
|
+
"list_maps",
|
|
59
|
+
"show_maps",
|
|
60
|
+
"attribution",
|
|
61
|
+
"show_attribution",
|
|
62
|
+
"texture_license_notes",
|
|
63
|
+
"kernel_license_notes",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def kernel_path(key: str) -> Path:
|
|
68
|
+
"""Where kernel `key` (id or filename) would live in the cache."""
|
|
69
|
+
e = find_kernel(key)
|
|
70
|
+
sub = e.get("subdir", "")
|
|
71
|
+
base = kernels_dir()
|
|
72
|
+
return (base / sub / e["filename"]) if sub else (base / e["filename"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def texture_path(body: str, variant: Optional[str] = None) -> Path:
|
|
76
|
+
"""Where the texture for `body` (+ optional variant) would live."""
|
|
77
|
+
e = find_texture(body, variant)
|
|
78
|
+
fname = e.get("filename") or e["asset_url"].rsplit("/", 1)[-1]
|
|
79
|
+
return maps_dir() / fname
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_kernel(key: str, *, download_if_missing: bool = True,
|
|
83
|
+
quiet: bool = False) -> Path:
|
|
84
|
+
"""Return a local path to SPICE kernel `key` (id or filename).
|
|
85
|
+
|
|
86
|
+
Downloads it into the kernels cache on first use unless
|
|
87
|
+
``download_if_missing=False`` (then a missing file raises).
|
|
88
|
+
"""
|
|
89
|
+
e = find_kernel(key)
|
|
90
|
+
dest = kernel_path(key)
|
|
91
|
+
if dest.exists():
|
|
92
|
+
return dest
|
|
93
|
+
if not download_if_missing:
|
|
94
|
+
raise FileNotFoundError(
|
|
95
|
+
f"Kernel {key!r} not cached at {dest} and download disabled."
|
|
96
|
+
)
|
|
97
|
+
return download(e["url"], dest,
|
|
98
|
+
expected_size=e.get("size_bytes"), quiet=quiet)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_texture(body: str, variant: Optional[str] = None, *,
|
|
102
|
+
download_if_missing: bool = True,
|
|
103
|
+
quiet: bool = False) -> Path:
|
|
104
|
+
"""Return a local path to the texture for `body` (+ optional variant).
|
|
105
|
+
|
|
106
|
+
Downloads it into the maps cache on first use. Raises with an
|
|
107
|
+
actionable hint if the manifest entry is manual-only (no
|
|
108
|
+
``asset_url``) or if ``download_if_missing=False`` and the file
|
|
109
|
+
isn't cached yet.
|
|
110
|
+
"""
|
|
111
|
+
e = find_texture(body, variant)
|
|
112
|
+
url = e.get("asset_url")
|
|
113
|
+
generator = e.get("generator")
|
|
114
|
+
fname = e.get("filename") or (url.rsplit("/", 1)[-1] if url else None)
|
|
115
|
+
if not fname:
|
|
116
|
+
raise ValueError(_manual_download_hint(e))
|
|
117
|
+
dest = maps_dir() / fname
|
|
118
|
+
if dest.exists():
|
|
119
|
+
return dest
|
|
120
|
+
if generator:
|
|
121
|
+
# Procedurally generated — local and free, so build regardless
|
|
122
|
+
# of download_if_missing.
|
|
123
|
+
from implanet.assets._synthetic import build
|
|
124
|
+
return build(generator, dest)
|
|
125
|
+
if not download_if_missing:
|
|
126
|
+
raise FileNotFoundError(_download_disabled_hint(e, dest))
|
|
127
|
+
if not url:
|
|
128
|
+
raise ValueError(_manual_download_hint(e))
|
|
129
|
+
path = download(url, dest,
|
|
130
|
+
expected_size=e.get("size_bytes_estimated"), quiet=quiet)
|
|
131
|
+
if not quiet:
|
|
132
|
+
_print_citation_hint(e)
|
|
133
|
+
return path
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _manual_download_hint(entry: dict) -> str:
|
|
137
|
+
"""Multi-line, actionable error for a manual-only texture: tells the
|
|
138
|
+
user where to download it from, what filename to save it as, and
|
|
139
|
+
which directory to drop it into so the next ``get_texture()`` call
|
|
140
|
+
just works."""
|
|
141
|
+
body = entry.get("body", "?")
|
|
142
|
+
variant = entry.get("variant") or "(default)"
|
|
143
|
+
portal = entry.get("portal_url") or "<no portal URL in registry>"
|
|
144
|
+
fname = (entry.get("filename")
|
|
145
|
+
or (entry["asset_url"].rsplit("/", 1)[-1]
|
|
146
|
+
if entry.get("asset_url") else "<see portal>"))
|
|
147
|
+
target_dir = maps_dir()
|
|
148
|
+
note = entry.get("note", "")
|
|
149
|
+
note_line = f"\n Note: {note}" if note else ""
|
|
150
|
+
return (
|
|
151
|
+
f"Texture {body}/{variant} is manual-only — the registry has "
|
|
152
|
+
f"no direct download URL, so implanet can't fetch it for you.\n"
|
|
153
|
+
f"\n"
|
|
154
|
+
f"To use it:\n"
|
|
155
|
+
f" 1. Download from: {portal}\n"
|
|
156
|
+
f" 2. Save it as: {fname}\n"
|
|
157
|
+
f" 3. Place it in: {target_dir}\n"
|
|
158
|
+
f"\n"
|
|
159
|
+
f"Then re-run your get_texture({body!r}"
|
|
160
|
+
+ (f", {entry['variant']!r}" if entry.get('variant') else "")
|
|
161
|
+
+ ") call — the file will be picked up from the cache."
|
|
162
|
+
+ note_line
|
|
163
|
+
+ f"\n\nFull license/citation: "
|
|
164
|
+
f"implanet.show_attribution({body!r})"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _download_disabled_hint(entry: dict, dest: Path) -> str:
|
|
169
|
+
"""Error for `download_if_missing=False` when the file isn't cached
|
|
170
|
+
yet — tells the user three ways to get it onto disk."""
|
|
171
|
+
body = entry.get("body", "?")
|
|
172
|
+
variant = entry.get("variant") or "(default)"
|
|
173
|
+
var_arg = (f", {entry['variant']!r}" if entry.get('variant') else "")
|
|
174
|
+
return (
|
|
175
|
+
f"Texture {body}/{variant} isn't cached at {dest} and "
|
|
176
|
+
f"download_if_missing=False was set.\n"
|
|
177
|
+
f"\n"
|
|
178
|
+
f"To populate the cache, pick one:\n"
|
|
179
|
+
f" • Python: implanet.get_texture({body!r}{var_arg}) "
|
|
180
|
+
f" (default download_if_missing=True will fetch it)\n"
|
|
181
|
+
f" • CLI: implanet-fetch --body {body}\n"
|
|
182
|
+
f" • Manual: drop the file at {dest} yourself"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _print_citation_hint(entry: dict) -> None:
|
|
187
|
+
"""One-line license + citation note, printed to stderr on a fresh
|
|
188
|
+
texture download so users don't miss attribution requirements."""
|
|
189
|
+
import sys
|
|
190
|
+
lic = entry.get("license") or "see registry"
|
|
191
|
+
cite = entry.get("citation") or entry.get("provenance") or ""
|
|
192
|
+
body = entry.get("body", "?")
|
|
193
|
+
variant = entry.get("variant", "")
|
|
194
|
+
head = f"[implanet] {body}/{variant} license: {lic}"
|
|
195
|
+
print(head, file=sys.stderr)
|
|
196
|
+
if cite:
|
|
197
|
+
print(f"[implanet] cite: {cite}", file=sys.stderr)
|
|
198
|
+
print("[implanet] full attribution: implanet.show_attribution"
|
|
199
|
+
f"({body!r})", file=sys.stderr)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _status(entry: dict, cached: bool) -> str:
|
|
203
|
+
if cached:
|
|
204
|
+
return "cached"
|
|
205
|
+
if entry.get("generator"):
|
|
206
|
+
return "generate"
|
|
207
|
+
if entry.get("asset_url"):
|
|
208
|
+
return "download"
|
|
209
|
+
return "manual"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def list_maps(body: Optional[str] = None,
|
|
213
|
+
downloadable_only: bool = False) -> list:
|
|
214
|
+
"""Return a list of dicts summarising every texture in the registry.
|
|
215
|
+
|
|
216
|
+
Each dict: ``body, variant, agency, mission, resolution, format,
|
|
217
|
+
size_bytes, status`` (``cached`` / ``download`` / ``generate`` /
|
|
218
|
+
``manual``), ``cached`` (bool), ``filename``, ``path``.
|
|
219
|
+
|
|
220
|
+
Examples
|
|
221
|
+
--------
|
|
222
|
+
>>> from implanet import list_maps
|
|
223
|
+
>>> [m['variant'] for m in list_maps(body='Mars')]
|
|
224
|
+
['sss', 'viking_mdim21_1km', ...]
|
|
225
|
+
"""
|
|
226
|
+
out = []
|
|
227
|
+
for e in texture_entries():
|
|
228
|
+
if body and e["body"].lower() != body.lower():
|
|
229
|
+
continue
|
|
230
|
+
url = e.get("asset_url")
|
|
231
|
+
downloadable = bool(url) or bool(e.get("generator"))
|
|
232
|
+
if downloadable_only and not downloadable:
|
|
233
|
+
continue
|
|
234
|
+
fname = e.get("filename") or (url.rsplit("/", 1)[-1] if url else None)
|
|
235
|
+
path = (maps_dir() / fname) if fname else None
|
|
236
|
+
cached = bool(path and path.exists())
|
|
237
|
+
out.append({
|
|
238
|
+
"body": e["body"],
|
|
239
|
+
"variant": e.get("variant"),
|
|
240
|
+
"agency": e.get("agency"),
|
|
241
|
+
"mission": e.get("mission"),
|
|
242
|
+
"resolution": e.get("resolution"),
|
|
243
|
+
"format": e.get("format"),
|
|
244
|
+
"size_bytes": e.get("size_bytes_estimated"),
|
|
245
|
+
"status": _status(e, cached),
|
|
246
|
+
"cached": cached,
|
|
247
|
+
"filename": fname,
|
|
248
|
+
"path": str(path) if path else None,
|
|
249
|
+
})
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def show_maps(body: Optional[str] = None,
|
|
254
|
+
downloadable_only: bool = False) -> None:
|
|
255
|
+
"""Print a table of every available texture map.
|
|
256
|
+
|
|
257
|
+
Status column: ``cached`` (already on disk), ``download``
|
|
258
|
+
(auto-fetchable), ``generate`` (synthetic, built locally),
|
|
259
|
+
``manual`` (portal-only). Filter with `body=` /
|
|
260
|
+
`downloadable_only=`.
|
|
261
|
+
|
|
262
|
+
Examples
|
|
263
|
+
--------
|
|
264
|
+
>>> import implanet
|
|
265
|
+
>>> implanet.show_maps()
|
|
266
|
+
>>> implanet.show_maps(body='Earth')
|
|
267
|
+
"""
|
|
268
|
+
rows = list_maps(body, downloadable_only)
|
|
269
|
+
print(f"{len(rows)} map(s) (cache dir: {maps_dir()})\n")
|
|
270
|
+
hdr = f"{'BODY':<10} {'VARIANT':<26} {'AGENCY':<9} {'RES':<11} {'SIZE':<9} STATUS"
|
|
271
|
+
print(hdr)
|
|
272
|
+
print("-" * len(hdr))
|
|
273
|
+
for m in rows:
|
|
274
|
+
size = "-" if m["size_bytes"] is None else _human(m["size_bytes"])
|
|
275
|
+
print(f"{m['body']:<10} {str(m['variant'] or ''):<26} "
|
|
276
|
+
f"{str(m['agency'] or ''):<9} {str(m['resolution'] or ''):<11} "
|
|
277
|
+
f"{size:<9} {m['status']}")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def attribution(body: str, variant: Optional[str] = None) -> dict:
|
|
281
|
+
"""Return the citation / license info for a texture.
|
|
282
|
+
|
|
283
|
+
Pulled directly from ``maps/manifest.json`` so it stays in sync with
|
|
284
|
+
the catalogue. The returned dict has these fields (any may be empty
|
|
285
|
+
if the registry doesn't supply them):
|
|
286
|
+
|
|
287
|
+
``body, variant, agency, mission, instrument, provenance,
|
|
288
|
+
license, citation, portal_url, asset_url, note,
|
|
289
|
+
umbrella_license_notes``
|
|
290
|
+
|
|
291
|
+
The last field is the top-level note that applies to *all* textures
|
|
292
|
+
(agency-level terms — e.g. NASA public domain, ESA CC BY-SA 3.0
|
|
293
|
+
IGO, JAXA-specific terms). Cite **both** the texture provider and
|
|
294
|
+
the underlying mission/instrument when publishing.
|
|
295
|
+
|
|
296
|
+
Examples
|
|
297
|
+
--------
|
|
298
|
+
>>> from implanet import attribution
|
|
299
|
+
>>> a = attribution("Mars")
|
|
300
|
+
>>> a["license"], a["citation"] # doctest: +SKIP
|
|
301
|
+
"""
|
|
302
|
+
e = find_texture(body, variant)
|
|
303
|
+
return {
|
|
304
|
+
"body": e.get("body"),
|
|
305
|
+
"variant": e.get("variant"),
|
|
306
|
+
"agency": e.get("agency"),
|
|
307
|
+
"mission": e.get("mission"),
|
|
308
|
+
"instrument": e.get("instrument"),
|
|
309
|
+
"provenance": e.get("provenance"),
|
|
310
|
+
"license": e.get("license"),
|
|
311
|
+
"citation": e.get("citation"),
|
|
312
|
+
"portal_url": e.get("portal_url"),
|
|
313
|
+
"asset_url": e.get("asset_url"),
|
|
314
|
+
"note": e.get("note"),
|
|
315
|
+
"umbrella_license_notes": texture_license_notes(),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def show_attribution(body: Optional[str] = None) -> None:
|
|
320
|
+
"""Print the citation / license block for one body, or for all
|
|
321
|
+
textures if ``body`` is None.
|
|
322
|
+
|
|
323
|
+
Use this *before publishing* any figure made with implanet — the
|
|
324
|
+
text shown is what you should reproduce (or paraphrase) as the
|
|
325
|
+
figure source line, plus the upstream mission credit.
|
|
326
|
+
|
|
327
|
+
Examples
|
|
328
|
+
--------
|
|
329
|
+
>>> import implanet
|
|
330
|
+
>>> implanet.show_attribution("Mars")
|
|
331
|
+
>>> implanet.show_attribution() # all entries
|
|
332
|
+
"""
|
|
333
|
+
notes = texture_license_notes()
|
|
334
|
+
if notes:
|
|
335
|
+
print("UMBRELLA LICENSE NOTES")
|
|
336
|
+
print("-" * 22)
|
|
337
|
+
# Wrap the umbrella note to ~80 cols for readability.
|
|
338
|
+
import textwrap
|
|
339
|
+
for line in textwrap.wrap(notes, width=78):
|
|
340
|
+
print(line)
|
|
341
|
+
print()
|
|
342
|
+
|
|
343
|
+
entries = list(texture_entries())
|
|
344
|
+
if body:
|
|
345
|
+
body_l = body.lower()
|
|
346
|
+
entries = [e for e in entries if e["body"].lower() == body_l]
|
|
347
|
+
if not entries:
|
|
348
|
+
raise KeyError(f"No texture for body {body!r} in manifest.json")
|
|
349
|
+
|
|
350
|
+
for e in entries:
|
|
351
|
+
head = f"{e['body']} / {e.get('variant', '')}"
|
|
352
|
+
print(head)
|
|
353
|
+
print("=" * len(head))
|
|
354
|
+
for key in ("agency", "mission", "instrument", "provenance",
|
|
355
|
+
"license", "citation", "portal_url", "note"):
|
|
356
|
+
v = e.get(key)
|
|
357
|
+
if v:
|
|
358
|
+
print(f" {key:<11}: {v}")
|
|
359
|
+
print()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Cache-directory resolution and a shared, resumable-ish downloader.
|
|
2
|
+
|
|
3
|
+
Resolution order for the cache roots (first match wins):
|
|
4
|
+
|
|
5
|
+
Kernels:
|
|
6
|
+
1. ``$IMPLANET_KERNELS`` (explicit dir; back-compat)
|
|
7
|
+
2. ``$IMPLANET_CACHE/kernels``
|
|
8
|
+
3. ``<repo>/kernels`` (in-repo dev checkout)
|
|
9
|
+
4. ``<site-packages>/implanet/_data/kernels`` (pip default)
|
|
10
|
+
5. ``<user-cache>/implanet/kernels`` (only if 4 is read-only)
|
|
11
|
+
|
|
12
|
+
Maps/textures: same shape, with ``_data/maps`` / ``maps``.
|
|
13
|
+
|
|
14
|
+
The pip-installed default keeps assets *with the package*
|
|
15
|
+
(``site-packages/implanet/_data``). If that directory isn't writable
|
|
16
|
+
(system-managed Python, read-only install), it transparently falls back
|
|
17
|
+
to the user cache so downloads never hard-fail. ``<user-cache>`` is
|
|
18
|
+
``$XDG_CACHE_HOME`` if set, else ``~/.cache`` (``~/Library/Caches`` on
|
|
19
|
+
macOS, ``%LOCALAPPDATA%`` on Windows).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from functools import lru_cache
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _user_cache_base() -> Path:
|
|
35
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
36
|
+
if xdg:
|
|
37
|
+
return Path(xdg)
|
|
38
|
+
if sys.platform == "darwin":
|
|
39
|
+
return Path.home() / "Library" / "Caches"
|
|
40
|
+
if os.name == "nt":
|
|
41
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
42
|
+
if local:
|
|
43
|
+
return Path(local)
|
|
44
|
+
return Path.home() / ".cache"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _find_repo_root() -> Optional[Path]:
|
|
48
|
+
"""Walk up from this file and cwd looking for the repo's maps/manifest.json."""
|
|
49
|
+
candidates = [Path(__file__).resolve(), Path.cwd().resolve()]
|
|
50
|
+
for start in candidates:
|
|
51
|
+
for parent in [start, *start.parents]:
|
|
52
|
+
if (parent / "maps" / "manifest.json").is_file():
|
|
53
|
+
return parent
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _package_base() -> Path:
|
|
58
|
+
"""``site-packages/implanet/_data`` (this file is implanet/assets/_cache.py)."""
|
|
59
|
+
return Path(__file__).resolve().parent.parent / "_data"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@lru_cache(maxsize=1)
|
|
63
|
+
def _default_base() -> Path:
|
|
64
|
+
"""Default asset root when no env override and not a dev checkout.
|
|
65
|
+
|
|
66
|
+
Keep assets *with the package* (the user's stated preference). Probe
|
|
67
|
+
the package dir for writability once; if it's read-only (system
|
|
68
|
+
Python, managed install), fall back to the user cache so downloads
|
|
69
|
+
never hard-fail. Cached so the probe runs at most once per process.
|
|
70
|
+
"""
|
|
71
|
+
pkg = _package_base()
|
|
72
|
+
try:
|
|
73
|
+
pkg.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
probe = pkg / ".write_test"
|
|
75
|
+
probe.write_text("")
|
|
76
|
+
probe.unlink()
|
|
77
|
+
return pkg
|
|
78
|
+
except OSError:
|
|
79
|
+
return _user_cache_base() / "implanet"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cache_base() -> Path:
|
|
83
|
+
"""Root under which the implanet user cache lives."""
|
|
84
|
+
explicit = os.environ.get("IMPLANET_CACHE")
|
|
85
|
+
if explicit:
|
|
86
|
+
return Path(explicit)
|
|
87
|
+
return _user_cache_base() / "implanet"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def kernels_dir() -> Path:
|
|
91
|
+
env = os.environ.get("IMPLANET_KERNELS")
|
|
92
|
+
if env:
|
|
93
|
+
return Path(env)
|
|
94
|
+
if os.environ.get("IMPLANET_CACHE"):
|
|
95
|
+
return cache_base() / "kernels"
|
|
96
|
+
repo = _find_repo_root()
|
|
97
|
+
if repo is not None and (repo / "kernels").is_dir():
|
|
98
|
+
return repo / "kernels"
|
|
99
|
+
return _default_base() / "kernels"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def maps_dir() -> Path:
|
|
103
|
+
env = os.environ.get("IMPLANET_MAPS")
|
|
104
|
+
if env:
|
|
105
|
+
return Path(env)
|
|
106
|
+
if os.environ.get("IMPLANET_CACHE"):
|
|
107
|
+
return cache_base() / "maps"
|
|
108
|
+
repo = _find_repo_root()
|
|
109
|
+
if repo is not None and (repo / "maps" / "data").is_dir():
|
|
110
|
+
return repo / "maps" / "data"
|
|
111
|
+
return _default_base() / "maps"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _human(n: Optional[int]) -> str:
|
|
115
|
+
if not n:
|
|
116
|
+
return "unknown size"
|
|
117
|
+
s = float(n)
|
|
118
|
+
for u in ("B", "KB", "MB", "GB", "TB"):
|
|
119
|
+
if s < 1024.0:
|
|
120
|
+
return f"{s:.1f} {u}"
|
|
121
|
+
s /= 1024.0
|
|
122
|
+
return f"{s:.1f} PB"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def download(url: str, dest: Path, *, expected_size: Optional[int] = None,
|
|
126
|
+
retries: int = 4, timeout: int = 120,
|
|
127
|
+
quiet: bool = False) -> Path:
|
|
128
|
+
"""Download `url` to `dest` atomically; return `dest`.
|
|
129
|
+
|
|
130
|
+
Skips the download if `dest` already exists and (when known) matches
|
|
131
|
+
`expected_size`. Writes to a ``.part`` sidecar and renames on
|
|
132
|
+
success so an interrupted download never leaves a half file in place.
|
|
133
|
+
"""
|
|
134
|
+
if dest.exists():
|
|
135
|
+
if expected_size is None or dest.stat().st_size == expected_size:
|
|
136
|
+
return dest
|
|
137
|
+
# Size mismatch — re-fetch.
|
|
138
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
140
|
+
headers = {"User-Agent": "implanet (research; +https://pypi.org/project/implanet)"}
|
|
141
|
+
|
|
142
|
+
for attempt in range(1, retries + 1):
|
|
143
|
+
try:
|
|
144
|
+
req = urllib.request.Request(url, headers=headers)
|
|
145
|
+
with urllib.request.urlopen(req, timeout=timeout) as r, open(tmp, "wb") as f:
|
|
146
|
+
total = int(r.headers.get("Content-Length") or 0)
|
|
147
|
+
read = 0
|
|
148
|
+
last_pct = -1
|
|
149
|
+
while True:
|
|
150
|
+
buf = r.read(1 << 16)
|
|
151
|
+
if not buf:
|
|
152
|
+
break
|
|
153
|
+
f.write(buf)
|
|
154
|
+
read += len(buf)
|
|
155
|
+
if not quiet and total:
|
|
156
|
+
pct = int(100 * read / total)
|
|
157
|
+
if pct != last_pct and pct % 5 == 0:
|
|
158
|
+
print(f" {pct:3d}% ({_human(read)} / "
|
|
159
|
+
f"{_human(total)})", end="\r")
|
|
160
|
+
last_pct = pct
|
|
161
|
+
if not quiet and total:
|
|
162
|
+
print(" " * 64, end="\r")
|
|
163
|
+
tmp.replace(dest)
|
|
164
|
+
return dest
|
|
165
|
+
except urllib.error.HTTPError as exc:
|
|
166
|
+
if exc.code in (429, 500, 502, 503) and attempt < retries:
|
|
167
|
+
wait = 5 * attempt
|
|
168
|
+
if not quiet:
|
|
169
|
+
print(f" HTTP {exc.code}; retry {attempt}/{retries-1} "
|
|
170
|
+
f"in {wait}s")
|
|
171
|
+
time.sleep(wait)
|
|
172
|
+
continue
|
|
173
|
+
raise
|
|
174
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
175
|
+
if attempt < retries:
|
|
176
|
+
wait = 3 * attempt
|
|
177
|
+
if not quiet:
|
|
178
|
+
print(f" {exc}; retry {attempt}/{retries-1} in {wait}s")
|
|
179
|
+
time.sleep(wait)
|
|
180
|
+
continue
|
|
181
|
+
raise
|
|
182
|
+
raise RuntimeError(f"unreachable: download retry loop exited for {url}")
|