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 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}")