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.
- spacescans/__init__.py +42 -0
- spacescans/__main__.py +7 -0
- spacescans/_capabilities.py +233 -0
- spacescans/_extras.py +65 -0
- spacescans/bridge/__init__.py +19 -0
- spacescans/bridge/__main__.py +7 -0
- spacescans/bridge/args.py +135 -0
- spacescans/bridge/cmd_catalog.py +274 -0
- spacescans/bridge/cmd_data.py +159 -0
- spacescans/bridge/cmd_datasets.py +149 -0
- spacescans/bridge/cmd_link.py +503 -0
- spacescans/bridge/cohorts.py +58 -0
- spacescans/bridge/commands.py +375 -0
- spacescans/bridge/configs.py +180 -0
- spacescans/bridge/errors.py +258 -0
- spacescans/bridge/events.py +129 -0
- spacescans/bridge/main.py +251 -0
- spacescans/bridge/protocol.py +153 -0
- spacescans/bridge/worker.py +186 -0
- spacescans/catalog/__init__.py +87 -0
- spacescans/catalog/_coverage.py +36 -0
- spacescans/catalog/_features.py +79 -0
- spacescans/catalog/_registry.py +313 -0
- spacescans/catalog/_templates.py +84 -0
- spacescans/catalog/features.json +237 -0
- spacescans/catalog/recipes.py +675 -0
- spacescans/catalog/templates/c3/acag_grid_demo.yaml +19 -0
- spacescans/catalog/templates/c3/bg_us_2020_demo.yaml +75 -0
- spacescans/catalog/templates/c3/bg_us_demo.yaml +68 -0
- spacescans/catalog/templates/c3/county_us_demo.yaml +21 -0
- spacescans/catalog/templates/c3/custom_grid.yaml +31 -0
- spacescans/catalog/templates/c3/nhd_demo.yaml +18 -0
- spacescans/catalog/templates/c3/noise_grid_demo.yaml +18 -0
- spacescans/catalog/templates/c3/temis_grid_demo.yaml +30 -0
- spacescans/catalog/templates/c3/tiger_roads_demo.yaml +24 -0
- spacescans/catalog/templates/c3/tract_us_demo.yaml +68 -0
- spacescans/catalog/templates/c3/vnl_grid_demo.yaml +20 -0
- spacescans/catalog/templates/c3/zcta5_us_demo.yaml +21 -0
- spacescans/catalog/templates/c4/acag_demo.yaml +32 -0
- spacescans/catalog/templates/c4/bg_ndi_demo.yaml +44 -0
- spacescans/catalog/templates/c4/bg_wi_demo.yaml +39 -0
- spacescans/catalog/templates/c4/county_cbp_demo.yaml +51 -0
- spacescans/catalog/templates/c4/custom_areal.yaml +41 -0
- spacescans/catalog/templates/c4/nhd_bluespace_demo.yaml +38 -0
- spacescans/catalog/templates/c4/noise_demo.yaml +34 -0
- spacescans/catalog/templates/c4/temis_demo.yaml +37 -0
- spacescans/catalog/templates/c4/tiger_roads_demo.yaml +34 -0
- spacescans/catalog/templates/c4/tract_faqsd_demo.yaml +33 -0
- spacescans/catalog/templates/c4/tract_fara_demo.yaml +34 -0
- spacescans/catalog/templates/c4/vnl_demo.yaml +33 -0
- spacescans/catalog/templates/c4/zcta5_cbp_demo.yaml +50 -0
- spacescans/catalog/variables.json +264 -0
- spacescans/catalog/variables.schema.json +73 -0
- spacescans/cli/__init__.py +51 -0
- spacescans/cli/__main__.py +3 -0
- spacescans/cli/_init_config.py +34 -0
- spacescans/cli/_quickstart.py +88 -0
- spacescans/cli/_run.py +25 -0
- spacescans/cohort.py +683 -0
- spacescans/config_resolution.py +132 -0
- spacescans/custom/__init__.py +182 -0
- spacescans/custom/boundaries.py +60 -0
- spacescans/custom/dated.py +172 -0
- spacescans/custom/definition.py +701 -0
- spacescans/custom/diagnose.py +187 -0
- spacescans/custom/errors.py +29 -0
- spacescans/custom/features.py +480 -0
- spacescans/custom/keys.py +56 -0
- spacescans/custom/periods.py +326 -0
- spacescans/custom/prepare.py +239 -0
- spacescans/custom/prepare_raster.py +221 -0
- spacescans/custom/prepare_table.py +436 -0
- spacescans/custom/raster.py +252 -0
- spacescans/custom/registry.py +867 -0
- spacescans/custom/safety.py +71 -0
- spacescans/custom/steps.py +396 -0
- spacescans/custom/table.py +264 -0
- spacescans/custom/units.py +81 -0
- spacescans/custom/values.py +82 -0
- spacescans/data/__init__.py +160 -0
- spacescans/data/_adopt.py +158 -0
- spacescans/data/_fetch.py +1033 -0
- spacescans/data/_import.py +220 -0
- spacescans/data/_mirror.py +291 -0
- spacescans/data/_prepare.py +196 -0
- spacescans/data/_providers.py +288 -0
- spacescans/data/_requirements.py +130 -0
- spacescans/data/_status.py +507 -0
- spacescans/data/_verify.py +90 -0
- spacescans/data/cli.py +455 -0
- spacescans/data/errors.py +91 -0
- spacescans/data/extract.py +264 -0
- spacescans/data/layout.py +265 -0
- spacescans/data/lock.py +99 -0
- spacescans/data/manifest.json +546 -0
- spacescans/data/manifest.py +579 -0
- spacescans/data/manifest.schema.json +328 -0
- spacescans/data/paths.py +76 -0
- spacescans/data/receipts.py +107 -0
- spacescans/data/sources.py +162 -0
- spacescans/data/transport.py +202 -0
- spacescans/engine/__init__.py +0 -0
- spacescans/engine/duckdb_engine.py +228 -0
- spacescans/engine/utils.py +18 -0
- spacescans/geometry/__init__.py +0 -0
- spacescans/geometry/buffers.py +45 -0
- spacescans/geometry/grid_weights.py +186 -0
- spacescans/geometry/overlap.py +547 -0
- spacescans/geometry/proximity.py +73 -0
- spacescans/io/__init__.py +0 -0
- spacescans/io/readers.py +51 -0
- spacescans/io/spatial_readers.py +84 -0
- spacescans/io/writers.py +44 -0
- spacescans/linkage/__init__.py +0 -0
- spacescans/linkage/acag_linkage.py +127 -0
- spacescans/linkage/boundary_overlap_fast_linkage.py +59 -0
- spacescans/linkage/boundary_overlap_linkage.py +56 -0
- spacescans/linkage/cbp_fallback_linkage.py +161 -0
- spacescans/linkage/faqsd_linkage.py +39 -0
- spacescans/linkage/fara_linkage.py +345 -0
- spacescans/linkage/grid_weights_linkage.py +57 -0
- spacescans/linkage/gridded_linkage.py +102 -0
- spacescans/linkage/helpers.py +203 -0
- spacescans/linkage/nhd_proximity_linkage.py +458 -0
- spacescans/linkage/precomputed_areal_linkage.py +137 -0
- spacescans/linkage/precomputed_static_linkage.py +102 -0
- spacescans/linkage/proximity_linkage.py +172 -0
- spacescans/linkage/static_areal_linkage.py +56 -0
- spacescans/linkage/tiger_proximity_linkage.py +286 -0
- spacescans/linkage/yearly_areal_bg_vintage_linkage.py +158 -0
- spacescans/linkage/yearly_areal_linkage.py +74 -0
- spacescans/models/__init__.py +1 -0
- spacescans/models/config.py +226 -0
- spacescans/models/protocols.py +49 -0
- spacescans/models/raster_meta.py +17 -0
- spacescans/models/specs.py +71 -0
- spacescans/orchestrate/__init__.py +158 -0
- spacescans/orchestrate/cache.py +862 -0
- spacescans/orchestrate/context.py +96 -0
- spacescans/orchestrate/coverage.py +196 -0
- spacescans/orchestrate/events.py +291 -0
- spacescans/orchestrate/execute.py +621 -0
- spacescans/orchestrate/link.py +840 -0
- spacescans/orchestrate/locks.py +193 -0
- spacescans/orchestrate/memory.py +126 -0
- spacescans/orchestrate/memory_table.csv +33 -0
- spacescans/orchestrate/merge.py +435 -0
- spacescans/orchestrate/provenance.py +153 -0
- spacescans/orchestrate/render.py +106 -0
- spacescans/orchestrate/run.py +468 -0
- spacescans/orchestrate/runs.py +446 -0
- spacescans/pipeline/__init__.py +0 -0
- spacescans/pipeline/loader.py +55 -0
- spacescans/pipeline/registry.py +199 -0
- spacescans/pipeline/runner.py +58 -0
- spacescans/plugins/__init__.py +0 -0
- spacescans/plugins/prep/__init__.py +0 -0
- spacescans/plugins/prep/modis.py +6 -0
- spacescans/plugins/prep/prism.py +6 -0
- spacescans/plugins/prep/tiger.py +6 -0
- spacescans/plugins/readers/__init__.py +0 -0
- spacescans/plugins/readers/acag.py +137 -0
- spacescans/plugins/readers/faqsd.py +352 -0
- spacescans/plugins/readers/modis_ndvi.py +109 -0
- spacescans/plugins/readers/modis_part1.py +94 -0
- spacescans/plugins/readers/nhd.py +84 -0
- spacescans/plugins/readers/noise.py +123 -0
- spacescans/plugins/readers/prism.py +130 -0
- spacescans/plugins/readers/prism_part1.py +214 -0
- spacescans/plugins/readers/temis.py +277 -0
- spacescans/plugins/readers/tiger_roads.py +87 -0
- spacescans/plugins/readers/vnl.py +152 -0
- spacescans/py.typed +0 -0
- spacescans/resources/__init__.py +0 -0
- spacescans/resources/configs/__init__.py +0 -0
- spacescans/resources/configs/quickstart/__init__.py +0 -0
- spacescans/resources/configs/quickstart/end_to_end.yaml +23 -0
- spacescans/resources/configs/templates/__init__.py +0 -0
- spacescans/resources/configs/templates/c3/__init__.py +0 -0
- spacescans/resources/configs/templates/c3/county.yaml +20 -0
- spacescans/resources/configs/templates/c3/zcta5.yaml +20 -0
- spacescans/resources/configs/templates/c4/__init__.py +0 -0
- spacescans/resources/configs/templates/c4/cbp_fallback.yaml +45 -0
- spacescans/resources/configs/templates/c4/zbp.yaml +44 -0
- spacescans/resources/data/__init__.py +0 -0
- spacescans/resources/data/sample_counties.cpg +1 -0
- spacescans/resources/data/sample_counties.dbf +0 -0
- spacescans/resources/data/sample_counties.prj +1 -0
- spacescans/resources/data/sample_counties.shp +0 -0
- spacescans/resources/data/sample_counties.shx +0 -0
- spacescans/resources/data/sample_patients.parquet +0 -0
- spacescans/resources/data/sample_zcta5.cpg +1 -0
- spacescans/resources/data/sample_zcta5.dbf +0 -0
- spacescans/resources/data/sample_zcta5.prj +1 -0
- spacescans/resources/data/sample_zcta5.shp +0 -0
- spacescans/resources/data/sample_zcta5.shx +0 -0
- spacescans/tools/__init__.py +0 -0
- spacescans/tools/temis_convert.py +136 -0
- spacescans/transforms/__init__.py +0 -0
- spacescans/transforms/date_parse.py +39 -0
- spacescans/transforms/derive.py +63 -0
- spacescans/transforms/filter.py +17 -0
- spacescans/transforms/recode.py +45 -0
- spacescans/transforms/spatial_fix.py +75 -0
- spacescans-0.3.0.dist-info/METADATA +439 -0
- spacescans-0.3.0.dist-info/RECORD +210 -0
- spacescans-0.3.0.dist-info/WHEEL +5 -0
- spacescans-0.3.0.dist-info/entry_points.txt +2 -0
- spacescans-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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,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,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
|