spacescans 0.3.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 (210) hide show
  1. spacescans/__init__.py +42 -0
  2. spacescans/__main__.py +7 -0
  3. spacescans/_capabilities.py +233 -0
  4. spacescans/_extras.py +65 -0
  5. spacescans/bridge/__init__.py +19 -0
  6. spacescans/bridge/__main__.py +7 -0
  7. spacescans/bridge/args.py +135 -0
  8. spacescans/bridge/cmd_catalog.py +274 -0
  9. spacescans/bridge/cmd_data.py +159 -0
  10. spacescans/bridge/cmd_datasets.py +149 -0
  11. spacescans/bridge/cmd_link.py +503 -0
  12. spacescans/bridge/cohorts.py +58 -0
  13. spacescans/bridge/commands.py +375 -0
  14. spacescans/bridge/configs.py +180 -0
  15. spacescans/bridge/errors.py +258 -0
  16. spacescans/bridge/events.py +129 -0
  17. spacescans/bridge/main.py +251 -0
  18. spacescans/bridge/protocol.py +153 -0
  19. spacescans/bridge/worker.py +186 -0
  20. spacescans/catalog/__init__.py +87 -0
  21. spacescans/catalog/_coverage.py +36 -0
  22. spacescans/catalog/_features.py +79 -0
  23. spacescans/catalog/_registry.py +313 -0
  24. spacescans/catalog/_templates.py +84 -0
  25. spacescans/catalog/features.json +237 -0
  26. spacescans/catalog/recipes.py +675 -0
  27. spacescans/catalog/templates/c3/acag_grid_demo.yaml +19 -0
  28. spacescans/catalog/templates/c3/bg_us_2020_demo.yaml +75 -0
  29. spacescans/catalog/templates/c3/bg_us_demo.yaml +68 -0
  30. spacescans/catalog/templates/c3/county_us_demo.yaml +21 -0
  31. spacescans/catalog/templates/c3/custom_grid.yaml +31 -0
  32. spacescans/catalog/templates/c3/nhd_demo.yaml +18 -0
  33. spacescans/catalog/templates/c3/noise_grid_demo.yaml +18 -0
  34. spacescans/catalog/templates/c3/temis_grid_demo.yaml +30 -0
  35. spacescans/catalog/templates/c3/tiger_roads_demo.yaml +24 -0
  36. spacescans/catalog/templates/c3/tract_us_demo.yaml +68 -0
  37. spacescans/catalog/templates/c3/vnl_grid_demo.yaml +20 -0
  38. spacescans/catalog/templates/c3/zcta5_us_demo.yaml +21 -0
  39. spacescans/catalog/templates/c4/acag_demo.yaml +32 -0
  40. spacescans/catalog/templates/c4/bg_ndi_demo.yaml +44 -0
  41. spacescans/catalog/templates/c4/bg_wi_demo.yaml +39 -0
  42. spacescans/catalog/templates/c4/county_cbp_demo.yaml +51 -0
  43. spacescans/catalog/templates/c4/custom_areal.yaml +41 -0
  44. spacescans/catalog/templates/c4/nhd_bluespace_demo.yaml +38 -0
  45. spacescans/catalog/templates/c4/noise_demo.yaml +34 -0
  46. spacescans/catalog/templates/c4/temis_demo.yaml +37 -0
  47. spacescans/catalog/templates/c4/tiger_roads_demo.yaml +34 -0
  48. spacescans/catalog/templates/c4/tract_faqsd_demo.yaml +33 -0
  49. spacescans/catalog/templates/c4/tract_fara_demo.yaml +34 -0
  50. spacescans/catalog/templates/c4/vnl_demo.yaml +33 -0
  51. spacescans/catalog/templates/c4/zcta5_cbp_demo.yaml +50 -0
  52. spacescans/catalog/variables.json +264 -0
  53. spacescans/catalog/variables.schema.json +73 -0
  54. spacescans/cli/__init__.py +51 -0
  55. spacescans/cli/__main__.py +3 -0
  56. spacescans/cli/_init_config.py +34 -0
  57. spacescans/cli/_quickstart.py +88 -0
  58. spacescans/cli/_run.py +25 -0
  59. spacescans/cohort.py +683 -0
  60. spacescans/config_resolution.py +132 -0
  61. spacescans/custom/__init__.py +182 -0
  62. spacescans/custom/boundaries.py +60 -0
  63. spacescans/custom/dated.py +172 -0
  64. spacescans/custom/definition.py +701 -0
  65. spacescans/custom/diagnose.py +187 -0
  66. spacescans/custom/errors.py +29 -0
  67. spacescans/custom/features.py +480 -0
  68. spacescans/custom/keys.py +56 -0
  69. spacescans/custom/periods.py +326 -0
  70. spacescans/custom/prepare.py +239 -0
  71. spacescans/custom/prepare_raster.py +221 -0
  72. spacescans/custom/prepare_table.py +436 -0
  73. spacescans/custom/raster.py +252 -0
  74. spacescans/custom/registry.py +867 -0
  75. spacescans/custom/safety.py +71 -0
  76. spacescans/custom/steps.py +396 -0
  77. spacescans/custom/table.py +264 -0
  78. spacescans/custom/units.py +81 -0
  79. spacescans/custom/values.py +82 -0
  80. spacescans/data/__init__.py +160 -0
  81. spacescans/data/_adopt.py +158 -0
  82. spacescans/data/_fetch.py +1033 -0
  83. spacescans/data/_import.py +220 -0
  84. spacescans/data/_mirror.py +291 -0
  85. spacescans/data/_prepare.py +196 -0
  86. spacescans/data/_providers.py +288 -0
  87. spacescans/data/_requirements.py +130 -0
  88. spacescans/data/_status.py +507 -0
  89. spacescans/data/_verify.py +90 -0
  90. spacescans/data/cli.py +455 -0
  91. spacescans/data/errors.py +91 -0
  92. spacescans/data/extract.py +264 -0
  93. spacescans/data/layout.py +265 -0
  94. spacescans/data/lock.py +99 -0
  95. spacescans/data/manifest.json +546 -0
  96. spacescans/data/manifest.py +579 -0
  97. spacescans/data/manifest.schema.json +328 -0
  98. spacescans/data/paths.py +76 -0
  99. spacescans/data/receipts.py +107 -0
  100. spacescans/data/sources.py +162 -0
  101. spacescans/data/transport.py +202 -0
  102. spacescans/engine/__init__.py +0 -0
  103. spacescans/engine/duckdb_engine.py +228 -0
  104. spacescans/engine/utils.py +18 -0
  105. spacescans/geometry/__init__.py +0 -0
  106. spacescans/geometry/buffers.py +45 -0
  107. spacescans/geometry/grid_weights.py +186 -0
  108. spacescans/geometry/overlap.py +547 -0
  109. spacescans/geometry/proximity.py +73 -0
  110. spacescans/io/__init__.py +0 -0
  111. spacescans/io/readers.py +51 -0
  112. spacescans/io/spatial_readers.py +84 -0
  113. spacescans/io/writers.py +44 -0
  114. spacescans/linkage/__init__.py +0 -0
  115. spacescans/linkage/acag_linkage.py +127 -0
  116. spacescans/linkage/boundary_overlap_fast_linkage.py +59 -0
  117. spacescans/linkage/boundary_overlap_linkage.py +56 -0
  118. spacescans/linkage/cbp_fallback_linkage.py +161 -0
  119. spacescans/linkage/faqsd_linkage.py +39 -0
  120. spacescans/linkage/fara_linkage.py +345 -0
  121. spacescans/linkage/grid_weights_linkage.py +57 -0
  122. spacescans/linkage/gridded_linkage.py +102 -0
  123. spacescans/linkage/helpers.py +203 -0
  124. spacescans/linkage/nhd_proximity_linkage.py +458 -0
  125. spacescans/linkage/precomputed_areal_linkage.py +137 -0
  126. spacescans/linkage/precomputed_static_linkage.py +102 -0
  127. spacescans/linkage/proximity_linkage.py +172 -0
  128. spacescans/linkage/static_areal_linkage.py +56 -0
  129. spacescans/linkage/tiger_proximity_linkage.py +286 -0
  130. spacescans/linkage/yearly_areal_bg_vintage_linkage.py +158 -0
  131. spacescans/linkage/yearly_areal_linkage.py +74 -0
  132. spacescans/models/__init__.py +1 -0
  133. spacescans/models/config.py +226 -0
  134. spacescans/models/protocols.py +49 -0
  135. spacescans/models/raster_meta.py +17 -0
  136. spacescans/models/specs.py +71 -0
  137. spacescans/orchestrate/__init__.py +158 -0
  138. spacescans/orchestrate/cache.py +862 -0
  139. spacescans/orchestrate/context.py +96 -0
  140. spacescans/orchestrate/coverage.py +196 -0
  141. spacescans/orchestrate/events.py +291 -0
  142. spacescans/orchestrate/execute.py +621 -0
  143. spacescans/orchestrate/link.py +840 -0
  144. spacescans/orchestrate/locks.py +193 -0
  145. spacescans/orchestrate/memory.py +126 -0
  146. spacescans/orchestrate/memory_table.csv +33 -0
  147. spacescans/orchestrate/merge.py +435 -0
  148. spacescans/orchestrate/provenance.py +153 -0
  149. spacescans/orchestrate/render.py +106 -0
  150. spacescans/orchestrate/run.py +468 -0
  151. spacescans/orchestrate/runs.py +446 -0
  152. spacescans/pipeline/__init__.py +0 -0
  153. spacescans/pipeline/loader.py +55 -0
  154. spacescans/pipeline/registry.py +199 -0
  155. spacescans/pipeline/runner.py +58 -0
  156. spacescans/plugins/__init__.py +0 -0
  157. spacescans/plugins/prep/__init__.py +0 -0
  158. spacescans/plugins/prep/modis.py +6 -0
  159. spacescans/plugins/prep/prism.py +6 -0
  160. spacescans/plugins/prep/tiger.py +6 -0
  161. spacescans/plugins/readers/__init__.py +0 -0
  162. spacescans/plugins/readers/acag.py +137 -0
  163. spacescans/plugins/readers/faqsd.py +352 -0
  164. spacescans/plugins/readers/modis_ndvi.py +109 -0
  165. spacescans/plugins/readers/modis_part1.py +94 -0
  166. spacescans/plugins/readers/nhd.py +84 -0
  167. spacescans/plugins/readers/noise.py +123 -0
  168. spacescans/plugins/readers/prism.py +130 -0
  169. spacescans/plugins/readers/prism_part1.py +214 -0
  170. spacescans/plugins/readers/temis.py +277 -0
  171. spacescans/plugins/readers/tiger_roads.py +87 -0
  172. spacescans/plugins/readers/vnl.py +152 -0
  173. spacescans/py.typed +0 -0
  174. spacescans/resources/__init__.py +0 -0
  175. spacescans/resources/configs/__init__.py +0 -0
  176. spacescans/resources/configs/quickstart/__init__.py +0 -0
  177. spacescans/resources/configs/quickstart/end_to_end.yaml +23 -0
  178. spacescans/resources/configs/templates/__init__.py +0 -0
  179. spacescans/resources/configs/templates/c3/__init__.py +0 -0
  180. spacescans/resources/configs/templates/c3/county.yaml +20 -0
  181. spacescans/resources/configs/templates/c3/zcta5.yaml +20 -0
  182. spacescans/resources/configs/templates/c4/__init__.py +0 -0
  183. spacescans/resources/configs/templates/c4/cbp_fallback.yaml +45 -0
  184. spacescans/resources/configs/templates/c4/zbp.yaml +44 -0
  185. spacescans/resources/data/__init__.py +0 -0
  186. spacescans/resources/data/sample_counties.cpg +1 -0
  187. spacescans/resources/data/sample_counties.dbf +0 -0
  188. spacescans/resources/data/sample_counties.prj +1 -0
  189. spacescans/resources/data/sample_counties.shp +0 -0
  190. spacescans/resources/data/sample_counties.shx +0 -0
  191. spacescans/resources/data/sample_patients.parquet +0 -0
  192. spacescans/resources/data/sample_zcta5.cpg +1 -0
  193. spacescans/resources/data/sample_zcta5.dbf +0 -0
  194. spacescans/resources/data/sample_zcta5.prj +1 -0
  195. spacescans/resources/data/sample_zcta5.shp +0 -0
  196. spacescans/resources/data/sample_zcta5.shx +0 -0
  197. spacescans/tools/__init__.py +0 -0
  198. spacescans/tools/temis_convert.py +136 -0
  199. spacescans/transforms/__init__.py +0 -0
  200. spacescans/transforms/date_parse.py +39 -0
  201. spacescans/transforms/derive.py +63 -0
  202. spacescans/transforms/filter.py +17 -0
  203. spacescans/transforms/recode.py +45 -0
  204. spacescans/transforms/spatial_fix.py +75 -0
  205. spacescans-0.3.0.dist-info/METADATA +439 -0
  206. spacescans-0.3.0.dist-info/RECORD +210 -0
  207. spacescans-0.3.0.dist-info/WHEEL +5 -0
  208. spacescans-0.3.0.dist-info/entry_points.txt +2 -0
  209. spacescans-0.3.0.dist-info/licenses/LICENSE +21 -0
  210. spacescans-0.3.0.dist-info/top_level.txt +1 -0
spacescans/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """spacescans — environmental exposure linkage pipeline."""
2
+ from __future__ import annotations
3
+ import sys
4
+
5
+ if sys.version_info < (3, 10):
6
+ raise ImportError(
7
+ f"spacescans requires Python 3.10+, got "
8
+ f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}."
9
+ )
10
+
11
+ # Single source of truth is pyproject.toml; read it back via package metadata
12
+ # so the version never drifts between the two files.
13
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError
14
+
15
+ # The distribution is "spacescans" since 0.3.0 (it was "spacescans-pipeline" before its first
16
+ # PyPI release); an environment installed before the rename still has the old metadata.
17
+ for _dist in ("spacescans", "spacescans-pipeline"):
18
+ try:
19
+ __version__ = _pkg_version(_dist)
20
+ break
21
+ except PackageNotFoundError:
22
+ continue
23
+ else: # running from a source tree that isn't installed
24
+ __version__ = "0.0.0+local"
25
+ del _dist
26
+
27
+ # Lazy public re-exports — imported on attribute access to keep base install
28
+ # import-light (avoids touching pipeline/runner.py until needed).
29
+ def __getattr__(name: str):
30
+ if name == "Pipeline":
31
+ from spacescans.pipeline.runner import Pipeline
32
+ return Pipeline
33
+ if name == "resolve_config":
34
+ from spacescans.config_resolution import resolve_config
35
+ return resolve_config
36
+ if name == "capabilities":
37
+ from spacescans._capabilities import capabilities
38
+ return capabilities
39
+ raise AttributeError(f"module 'spacescans' has no attribute {name!r}")
40
+
41
+
42
+ __all__ = ["Pipeline", "resolve_config", "capabilities", "__version__"]
spacescans/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m spacescans` — the same CLI as the `spacescans` console script."""
2
+ import sys
3
+
4
+ from spacescans.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,233 @@
1
+ """What this spacescans install is and what it can run.
2
+
3
+ ``capabilities()`` replaces the probes clients used to need (reading module
4
+ source for the word "output_grouping", poking at private registries). It is
5
+ cheap enough to call once per client session.
6
+
7
+ Module level imports the standard library only: the R package's wheel build
8
+ script imports ``fingerprint_wheel`` from a bare source tree.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ import hashlib
14
+ import importlib
15
+ import importlib.metadata
16
+ import importlib.util
17
+ import os
18
+ import platform
19
+ import sys
20
+ import zipfile
21
+ from collections.abc import Iterable
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ # The extras declared in pyproject.toml and the modules each one must provide.
26
+ EXTRAS: dict[str, tuple[str, ...]] = {
27
+ "geo": ("geopandas", "rasterio", "shapely", "exactextract"),
28
+ "rda": ("pyreadr",),
29
+ "hdf4": ("pyhdf",),
30
+ "nc": ("xarray", "netCDF4"),
31
+ }
32
+
33
+ # Behaviour flags clients may test instead of version numbers.
34
+ FLAGS: dict[str, bool] = {
35
+ "episode_grouping": True, # time.output_grouping = "episode" (0.2.x)
36
+ "config_from_dict": True, # load_config_dict / Pipeline.from_dict
37
+ "engine_options": True, # engine.threads / memory_limit / temp_directory
38
+ "unavailable_pattern_error": True, # registry raises UnavailablePatternError
39
+ # output_level ("participant" | "episode") in orchestrate.link, render_config, RunContext,
40
+ # the merge functions and the bridge's link; participant means in every C4 pattern (0.3)
41
+ "output_level": True,
42
+ "fara_missing_skip": True, # FARA leaves tracts without a value out of the buffer mean (0.3)
43
+ }
44
+
45
+ _DISTRIBUTIONS = (
46
+ "spacescans", "pydantic", "pyyaml", "pandas", "numpy", "duckdb", "pyarrow",
47
+ "mercantile", "geopandas", "rasterio", "shapely", "exactextract", "pyogrio", "pyproj",
48
+ "pyreadr", "pyhdf", "xarray", "netCDF4",
49
+ )
50
+
51
+
52
+ def fingerprint_entries(entries: Iterable[tuple[str, bytes]]) -> str:
53
+ """Hash (relative path, content) pairs into ``"sha256:<hex>"``.
54
+
55
+ Paths are POSIX, relative to the ``spacescans`` package directory. Order
56
+ does not matter: entries are sorted by path first.
57
+ """
58
+ h = hashlib.sha256()
59
+ for rel, data in sorted(entries, key=lambda e: e[0]):
60
+ h.update(rel.encode("utf-8"))
61
+ h.update(b"\0")
62
+ h.update(hashlib.sha256(data).digest())
63
+ h.update(b"\n")
64
+ return "sha256:" + h.hexdigest()
65
+
66
+
67
+ def _package_root() -> Path:
68
+ return Path(__file__).resolve().parent
69
+
70
+
71
+ @functools.cache
72
+ def code_fingerprint(root: str | os.PathLike | None = None) -> str:
73
+ """Fingerprint of every ``.py`` file under the installed ``spacescans`` package.
74
+
75
+ Detects editable or stale installs whose metadata version does not match
76
+ the code that actually runs.
77
+ """
78
+ base = Path(root) if root is not None else _package_root()
79
+ entries = []
80
+ for path in base.rglob("*.py"):
81
+ if "__pycache__" in path.parts:
82
+ continue
83
+ entries.append((path.relative_to(base).as_posix(), path.read_bytes()))
84
+ return fingerprint_entries(entries)
85
+
86
+
87
+ def fingerprint_wheel(wheel: str | os.PathLike) -> str:
88
+ """The code fingerprint the given wheel will have once installed."""
89
+ entries = []
90
+ with zipfile.ZipFile(wheel) as zf:
91
+ for name in zf.namelist():
92
+ if name.startswith("spacescans/") and name.endswith(".py"):
93
+ entries.append((name[len("spacescans/"):], zf.read(name)))
94
+ return fingerprint_entries(entries)
95
+
96
+
97
+ def extras_status() -> dict[str, dict[str, Any]]:
98
+ """For each extra: whether all its modules are importable, and which are missing."""
99
+ out = {}
100
+ for extra, modules in EXTRAS.items():
101
+ missing = [m for m in modules if importlib.util.find_spec(m) is None]
102
+ out[extra] = {"available": not missing, "missing": missing}
103
+ return out
104
+
105
+
106
+ def package_versions() -> dict[str, str | None]:
107
+ """Installed versions of spacescans and its (optional) dependencies, None if absent."""
108
+ out: dict[str, str | None] = {}
109
+ for dist in _DISTRIBUTIONS:
110
+ try:
111
+ out[dist] = importlib.metadata.version(dist)
112
+ except importlib.metadata.PackageNotFoundError:
113
+ out[dist] = None
114
+ return out
115
+
116
+
117
+ def native_versions() -> dict[str, str | None]:
118
+ """GDAL / GEOS / PROJ versions as the Python bindings see them (None if unavailable)."""
119
+ probes = {
120
+ "gdal": ("rasterio", "__gdal_version__"),
121
+ "gdal_pyogrio": ("pyogrio", "__gdal_version_string__"),
122
+ "geos": ("shapely", "geos_version_string"),
123
+ "proj": ("pyproj", "proj_version_str"),
124
+ }
125
+ out: dict[str, str | None] = {}
126
+ for key, (module, attr) in probes.items():
127
+ if importlib.util.find_spec(module) is None:
128
+ out[key] = None
129
+ continue
130
+ try:
131
+ out[key] = str(getattr(importlib.import_module(module), attr))
132
+ except Exception as e: # a broken binary wheel must not break capabilities()
133
+ out[key] = f"error: {type(e).__name__}: {e}"
134
+ return out
135
+
136
+
137
+ def platform_info() -> dict[str, Any]:
138
+ mac = platform.mac_ver()[0] or None
139
+ return {
140
+ "system": platform.system(),
141
+ "release": platform.release(),
142
+ "machine": platform.machine(),
143
+ "macos": mac,
144
+ "platform": platform.platform(),
145
+ }
146
+
147
+
148
+ def python_info() -> dict[str, Any]:
149
+ return {
150
+ "version": platform.python_version(),
151
+ "implementation": platform.python_implementation(),
152
+ "executable": sys.executable,
153
+ "prefix": sys.prefix,
154
+ }
155
+
156
+
157
+ def catalog_fingerprint(hashes: dict[str, str] | None = None) -> str:
158
+ """``"sha256:<hex>"`` over the packaged catalog templates.
159
+
160
+ Built from ``catalog.template_hashes()`` ({relpath: sha256 hex}) the same
161
+ way as :func:`fingerprint_entries` builds the code fingerprint, so it
162
+ equals ``fingerprint_entries((rel, template_bytes(rel)) ...)``. Two
163
+ installs with the same value render the same pipeline YAMLs.
164
+ """
165
+ if hashes is None:
166
+ from spacescans import catalog
167
+ hashes = catalog.template_hashes()
168
+ h = hashlib.sha256()
169
+ for rel in sorted(hashes):
170
+ h.update(rel.encode("utf-8"))
171
+ h.update(b"\0")
172
+ h.update(bytes.fromhex(hashes[rel]))
173
+ h.update(b"\n")
174
+ return "sha256:" + h.hexdigest()
175
+
176
+
177
+ def catalog_info() -> dict[str, Any]:
178
+ """The catalog this install ships: template fingerprint and counts, file hashes."""
179
+ from importlib import resources
180
+
181
+ from spacescans import catalog
182
+
183
+ def file_sha(name: str) -> str:
184
+ return hashlib.sha256(resources.files("spacescans.catalog").joinpath(name).read_bytes()).hexdigest()
185
+
186
+ hashes = catalog.template_hashes()
187
+ return {
188
+ "hash": catalog_fingerprint(hashes),
189
+ "templates": len(hashes),
190
+ "variables": len(catalog.variables()),
191
+ "experiments": catalog.experiments(),
192
+ "variables_sha256": file_sha("variables.json"),
193
+ "features_sha256": file_sha("features.json"),
194
+ }
195
+
196
+
197
+ def data_release() -> str | None:
198
+ """The data release the packaged data manifest describes (``manifest.json``)."""
199
+ from spacescans.data.manifest import load_manifest
200
+
201
+ return load_manifest().data_release
202
+
203
+
204
+ def _guarded(fn) -> Any:
205
+ try:
206
+ return fn()
207
+ except Exception as e: # a broken catalog or manifest must not break capabilities()
208
+ return {"error": f"{type(e).__name__}: {e}"}
209
+
210
+
211
+ def capabilities() -> dict[str, Any]:
212
+ """Version, code fingerprint, extras, registered patterns, native libraries,
213
+ the catalog fingerprint and the data release."""
214
+ import spacescans
215
+ from spacescans.pipeline import registry
216
+
217
+ release = _guarded(data_release)
218
+ return {
219
+ "spacescans": {
220
+ "version": spacescans.__version__,
221
+ "code_fp": code_fingerprint(),
222
+ "path": str(_package_root()),
223
+ },
224
+ "python": python_info(),
225
+ "platform": platform_info(),
226
+ "extras": extras_status(),
227
+ "registry": registry.available(),
228
+ "flags": dict(FLAGS),
229
+ "versions": package_versions(),
230
+ "native": native_versions(),
231
+ "catalog": _guarded(catalog_info),
232
+ "data_release": release if isinstance(release, str) else None,
233
+ }
spacescans/_extras.py ADDED
@@ -0,0 +1,65 @@
1
+ # src/spacescans/_extras.py
2
+ """Optional extras gating: clean errors when a feature needs `pip install [extra]`."""
3
+ from __future__ import annotations
4
+ import importlib.util
5
+ from typing import Sequence
6
+
7
+ INSTALL_DOCS_URL = "https://github.com/IU-Ultraman/spacescans#optional-extras"
8
+
9
+ # Which pip extra provides each optional third-party module. Used to explain an
10
+ # import failure that was not raised through `require()` (a bare
11
+ # ModuleNotFoundError from a module-level import).
12
+ EXTRA_FOR_MODULE = {
13
+ "geopandas": "geo",
14
+ "rasterio": "geo",
15
+ "shapely": "geo",
16
+ "exactextract": "geo",
17
+ "pyogrio": "geo",
18
+ "pyproj": "geo",
19
+ "pyreadr": "rda",
20
+ "pyhdf": "hdf4",
21
+ "xarray": "nc",
22
+ "netCDF4": "nc",
23
+ }
24
+
25
+
26
+ class MissingExtraError(ImportError):
27
+ """Raised when an optional extra is needed but not installed.
28
+
29
+ ``extra`` names the pip extra (for example ``"geo"``) and ``missing`` lists
30
+ the modules that could not be imported, when known. Both are optional so
31
+ ``MissingExtraError("message")`` keeps working.
32
+ """
33
+
34
+ def __init__(self, *args, extra: str | None = None, missing: Sequence[str] = (), **kwargs):
35
+ super().__init__(*args, **kwargs)
36
+ self.extra = extra
37
+ self.missing = tuple(missing)
38
+
39
+
40
+ def extra_for_module(name: str | None) -> str | None:
41
+ """Return the pip extra that provides top-level module ``name`` (or None)."""
42
+ if not name:
43
+ return None
44
+ return EXTRA_FOR_MODULE.get(name.split(".")[0])
45
+
46
+
47
+ def require(extra: str, *modules: str) -> None:
48
+ """Raise MissingExtraError if any listed module is not importable.
49
+
50
+ Use at the top of a module that depends on optional packages:
51
+
52
+ from spacescans._extras import require
53
+ require("geo", "geopandas", "rasterio")
54
+
55
+ The error message tells the user exactly how to install the missing extra.
56
+ """
57
+ missing = [m for m in modules if importlib.util.find_spec(m) is None]
58
+ if missing:
59
+ raise MissingExtraError(
60
+ f"This feature needs `pip install 'spacescans[{extra}]'`.\n"
61
+ f" Missing module(s): {', '.join(missing)}\n"
62
+ f" See: {INSTALL_DOCS_URL}",
63
+ extra=extra,
64
+ missing=missing,
65
+ )
@@ -0,0 +1,19 @@
1
+ """JSON-file protocol for out-of-process clients: the R package now, later the web.
2
+
3
+ python -I -u -X utf8 -m spacescans.bridge <command> --request req.json --result res.json
4
+
5
+ * The request file holds ``{"protocol": 1, "command", "client", "args", "verbose"}``.
6
+ * The result file always gets one envelope: ``{"protocol", "ok", "command",
7
+ "spacescans": {"version", "code_fp", ...}, "elapsed_s", "value", "warnings", "error"}``.
8
+ * stdout carries only JSON-lines events (``{"ev": ..., "t": ...}``); everything a
9
+ human should read goes to stderr.
10
+ * Exit codes: 0 ok, 1 handled error (result written, ``ok`` false), 2 protocol or
11
+ usage error, 130/143 interrupted by SIGINT/SIGTERM (result written when
12
+ possible); anything else is a crash.
13
+
14
+ This package imports with the base install only; commands import what they need.
15
+ The full contract is in docs/bridge-protocol.md in the source repository.
16
+ """
17
+ from spacescans.bridge.protocol import PROTOCOL, PROTOCOL_RANGE
18
+
19
+ __all__ = ["PROTOCOL", "PROTOCOL_RANGE"]
@@ -0,0 +1,7 @@
1
+ """`python -m spacescans.bridge <command> --request req.json --result res.json`."""
2
+ import sys
3
+
4
+ from spacescans.bridge.main import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,135 @@
1
+ """Checking command arguments that arrive as JSON (from R in particular).
2
+
3
+ R's jsonlite writes a length-1 vector as a scalar, so every list argument
4
+ also accepts a single value (``variables = "ndi"`` means ``["ndi"]``). Paths
5
+ must be absolute: the bridge runs with its cwd set to the client's run
6
+ directory, so a relative path would silently mean something else.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Mapping, Sequence
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from spacescans.bridge.errors import BridgeError
16
+
17
+
18
+ def bool_arg(args: Mapping[str, Any], name: str, default: bool = False) -> bool:
19
+ value = args.get(name, default)
20
+ if value is None:
21
+ return default
22
+ if not isinstance(value, bool):
23
+ raise BridgeError("config", f"argument '{name}' must be true or false")
24
+ return value
25
+
26
+
27
+ def int_arg(args: Mapping[str, Any], name: str, *, minimum: int | None = None,
28
+ default: int | None = None) -> int | None:
29
+ value = args.get(name)
30
+ if value is None:
31
+ return default
32
+ if isinstance(value, float) and value.is_integer():
33
+ value = int(value)
34
+ if isinstance(value, bool) or not isinstance(value, int):
35
+ raise BridgeError("config", f"argument '{name}' must be a whole number, got {value!r}")
36
+ if minimum is not None and value < minimum:
37
+ raise BridgeError("config", f"argument '{name}' must be at least {minimum}, got {value}")
38
+ return value
39
+
40
+
41
+ def number_arg(args: Mapping[str, Any], name: str, *, minimum: float | None = None,
42
+ maximum: float | None = None, default: float | None = None) -> float | None:
43
+ value = args.get(name)
44
+ if value is None:
45
+ return default
46
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
47
+ raise BridgeError("config", f"argument '{name}' must be a number, got {value!r}")
48
+ value = float(value)
49
+ if math.isnan(value):
50
+ raise BridgeError("config", f"argument '{name}' must be a number, got NaN")
51
+ if minimum is not None and value < minimum:
52
+ raise BridgeError("config", f"argument '{name}' must be at least {minimum:g}, got {value:g}")
53
+ if maximum is not None and value > maximum:
54
+ raise BridgeError("config", f"argument '{name}' must be at most {maximum:g}, got {value:g}")
55
+ return value
56
+
57
+
58
+ def str_arg(args: Mapping[str, Any], name: str, *, required: bool = False) -> str | None:
59
+ value = args.get(name)
60
+ if value is None:
61
+ if required:
62
+ raise BridgeError("config", f"argument '{name}' is required")
63
+ return None
64
+ if not isinstance(value, str) or not value:
65
+ raise BridgeError("config", f"argument '{name}' must be a non-empty string")
66
+ return value
67
+
68
+
69
+ def choice_arg(args: Mapping[str, Any], name: str, choices: Sequence[str], *,
70
+ default: str) -> str:
71
+ value = args.get(name)
72
+ if value is None:
73
+ return default
74
+ if value not in choices:
75
+ raise BridgeError("config", f"argument '{name}' must be one of {', '.join(map(repr, choices))}, "
76
+ f"got {value!r}", details={"argument": name, "choices": list(choices)})
77
+ return value
78
+
79
+
80
+ def str_list_arg(args: Mapping[str, Any], name: str, *, required: bool = False,
81
+ allow_empty: bool = True) -> list[str] | None:
82
+ """A list of non-empty strings; a single string counts as a list of one."""
83
+ value = args.get(name)
84
+ if value is None:
85
+ if required:
86
+ raise BridgeError("config", f"argument '{name}' is required")
87
+ return None
88
+ if isinstance(value, str):
89
+ value = [value]
90
+ if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
91
+ raise BridgeError("config", f"argument '{name}' must be a list of non-empty strings")
92
+ if not value and not allow_empty:
93
+ raise BridgeError("config", f"argument '{name}' must not be empty")
94
+ return list(value)
95
+
96
+
97
+ def abs_path(value: Any, name: str) -> str:
98
+ if not isinstance(value, str) or not value:
99
+ raise BridgeError("config", f"argument '{name}' must be a path string")
100
+ if not Path(value).is_absolute():
101
+ raise BridgeError("config", f"argument '{name}' must be an absolute path, got {value!r}",
102
+ details={"argument": name, "value": value})
103
+ return value
104
+
105
+
106
+ def path_arg(args: Mapping[str, Any], name: str, *, required: bool = False) -> str | None:
107
+ value = args.get(name)
108
+ if value is None:
109
+ if required:
110
+ raise BridgeError("config", f"argument '{name}' is required")
111
+ return None
112
+ return abs_path(value, name)
113
+
114
+
115
+ def path_list_arg(args: Mapping[str, Any], name: str, *, required: bool = False) -> list[str] | None:
116
+ value = args.get(name)
117
+ if value is None:
118
+ if required:
119
+ raise BridgeError("config", f"argument '{name}' is required")
120
+ return None
121
+ if isinstance(value, str):
122
+ value = [value]
123
+ if not isinstance(value, list):
124
+ raise BridgeError("config", f"argument '{name}' must be a list of absolute paths")
125
+ return [abs_path(v, name) for v in value]
126
+
127
+
128
+ def mapping_arg(args: Mapping[str, Any], name: str) -> dict[str, Any] | None:
129
+ """An object; ``[]`` (jsonlite's empty named list) counts as empty."""
130
+ value = args.get(name)
131
+ if value is None or value == []:
132
+ return None if value is None else {}
133
+ if not isinstance(value, dict):
134
+ raise BridgeError("config", f"argument '{name}' must be an object")
135
+ return value