scse-spatial 1.0.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 (71) hide show
  1. scse_spatial-1.0.0.dist-info/METADATA +136 -0
  2. scse_spatial-1.0.0.dist-info/RECORD +71 -0
  3. scse_spatial-1.0.0.dist-info/WHEEL +4 -0
  4. scse_spatial-1.0.0.dist-info/entry_points.txt +3 -0
  5. spatial/__init__.py +7 -0
  6. spatial/__main__.py +4 -0
  7. spatial/_version.py +2 -0
  8. spatial/cli/__init__.py +19 -0
  9. spatial/cli/alembic.py +85 -0
  10. spatial/cli/imports.py +89 -0
  11. spatial/cli/manager.py +161 -0
  12. spatial/cli/server.py +24 -0
  13. spatial/cli/workflow.py +367 -0
  14. spatial/conf/__init__.py +2 -0
  15. spatial/conf/credentials.py +73 -0
  16. spatial/conf/logging.json +44 -0
  17. spatial/conf/platforms.py +91 -0
  18. spatial/conf/settings.py +397 -0
  19. spatial/data/base/exceptions.py +24 -0
  20. spatial/data/base/filters.py +65 -0
  21. spatial/data/base/hierarchy.py +267 -0
  22. spatial/data/base/pagination.py +76 -0
  23. spatial/data/base/service.py +173 -0
  24. spatial/data/features/__init__.py +2 -0
  25. spatial/data/features/controller.py +113 -0
  26. spatial/data/features/db.py +136 -0
  27. spatial/data/features/dto.py +66 -0
  28. spatial/data/features/service.py +765 -0
  29. spatial/data/features/tiling.py +123 -0
  30. spatial/data/file_storage.py +362 -0
  31. spatial/data/hashing.py +30 -0
  32. spatial/data/manifest/__init__.py +7 -0
  33. spatial/data/manifest/db.py +127 -0
  34. spatial/data/rasters/__init__.py +4 -0
  35. spatial/data/rasters/controller.py +145 -0
  36. spatial/data/rasters/db.py +214 -0
  37. spatial/data/rasters/dto.py +86 -0
  38. spatial/data/rasters/service.py +2150 -0
  39. spatial/data/rasters/tile.py +35 -0
  40. spatial/data/stac/__init__.py +1 -0
  41. spatial/data/stac/io.py +81 -0
  42. spatial/data/stac/service.py +401 -0
  43. spatial/db/__init__.py +23 -0
  44. spatial/db/base.py +33 -0
  45. spatial/db/migrations/__init__.py +1 -0
  46. spatial/db/migrations/env.py +109 -0
  47. spatial/db/migrations/script.py.mako +26 -0
  48. spatial/db/migrations/versions/64901b988c97_initial.py +219 -0
  49. spatial/db/migrations/versions/7f3e2a1b9c4d_rename_url_columns_to_path.py +87 -0
  50. spatial/db/migrations/versions/8a2b3c4d5e6f_store_tile_params_in_metadata.py +45 -0
  51. spatial/db/migrations/versions/__init__.py +0 -0
  52. spatial/db/migrations/versions/a1b2c3d4e5f6_drop_raster_step_columns.py +35 -0
  53. spatial/db/migrations/versions/b1c3d5e7f9a1_add_raster_hierarchy_projection_epsg.py +32 -0
  54. spatial/db/migrations/versions/c5f3a4b8d9e7_drop_zarr_variable_and_feature_crs.py +34 -0
  55. spatial/db/migrations/versions/d2e4f6a8b0c2_add_jsonb_gin_indexes.py +48 -0
  56. spatial/db/migrations/versions/e3f5a7c9d1b2_add_actor_attribution.py +49 -0
  57. spatial/db/models.py +15 -0
  58. spatial/projections.py +287 -0
  59. spatial/py.typed +1 -0
  60. spatial/server/__init__.py +8 -0
  61. spatial/server/algorithm.py +54 -0
  62. spatial/server/app.py +203 -0
  63. spatial/server/data_serve.py +135 -0
  64. spatial/server/dependencies.py +103 -0
  65. spatial/server/exceptions.py +44 -0
  66. spatial/server/mounts.py +76 -0
  67. spatial/server/root.py +60 -0
  68. spatial/server/schemas.py +29 -0
  69. spatial/server/stac.py +98 -0
  70. spatial/server/titiler.py +323 -0
  71. spatial/spatial.py +282 -0
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: scse-spatial
3
+ Version: 1.0.0
4
+ Summary: A registry enabling the indexing and querying of spatial data resources on other services.
5
+ Author: Max Wolschlager
6
+ Author-email: rxtx@ungut.at
7
+ Requires-Python: >=3.11,<3.15
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: alembic (>=1.12.0,<2.0.0)
14
+ Requires-Dist: dask (>=2024.0.0)
15
+ Requires-Dist: geoalchemy2 (>=0.20.0,<0.21.0)
16
+ Requires-Dist: geopandas (>=1.0,<2.0)
17
+ Requires-Dist: jsonschema (>=4.26.0,<5.0.0)
18
+ Requires-Dist: litestar (>=2.0.0,<3.0.0)
19
+ Requires-Dist: mapbox-vector-tile (>=2,<3)
20
+ Requires-Dist: morecantile (>=7.0.3,<8.0)
21
+ Requires-Dist: netcdf4 (>=1.7.3,<2.0.0)
22
+ Requires-Dist: pmtiles (>=3.7,<4)
23
+ Requires-Dist: psycopg[binary] (>=3.1.12,<4.0.0)
24
+ Requires-Dist: pydantic (>=2.4.2,<3.0.0)
25
+ Requires-Dist: pydantic-settings (>=2.0.3,<3.0.0)
26
+ Requires-Dist: pyproj (>=3.1,<4.0)
27
+ Requires-Dist: pystac[validation] (>=1.12,<1.13)
28
+ Requires-Dist: rasterio (>=1.4.3,<2.0.0)
29
+ Requires-Dist: rich (>=13.9.4,<14.0.0)
30
+ Requires-Dist: rioxarray (>=0.19,<1.0)
31
+ Requires-Dist: s3fs (>=2024.10.0,<2027.0.0)
32
+ Requires-Dist: scse-toolkit (>=2,<3)
33
+ Requires-Dist: sqlalchemy (>=2.0.21,<3)
34
+ Requires-Dist: tippecanoe (>=2.45)
35
+ Requires-Dist: titiler-core (>=2.0,<3.0)
36
+ Requires-Dist: titiler-xarray[fs] (>=2.0,<3.0)
37
+ Requires-Dist: typer (>=0.24.1,<0.25.0)
38
+ Requires-Dist: uvicorn (>=0.52.1,<0.53.0)
39
+ Requires-Dist: xarray (>=2025.1.0,<2026)
40
+ Requires-Dist: xxhash (>=3.4.1,<4.0.0)
41
+ Requires-Dist: zarr (>=3.0.0,<4.0)
42
+ Description-Content-Type: text/markdown
43
+
44
+ # scse-spatial
45
+
46
+ A registry that indexes and queries spatial data resources on other services.
47
+
48
+ ## Overview
49
+
50
+ scse-spatial is the spatial data extension to a scenario services web
51
+ application stack. It turns geodata files into fast, web-optimized resources
52
+ and serves them to any map app.
53
+
54
+ A workflow loads data. A web app reads it. This repository provides both
55
+ sides: the ingest path and the HTTP API.
56
+
57
+ ### Feeding it
58
+
59
+ Workflows push data in with the CLI or a few lines of Python. The `Spatial`
60
+ object connects to a PostGIS database and to object storage. It exposes
61
+ services for rasters, features, and a STAC catalog.
62
+
63
+ ```python
64
+ with Spatial("<platform>", settings=Settings()) as spatial:
65
+ spatial.rasters.sync_hierarchy(
66
+ "emissions",
67
+ [path],
68
+ parameters_class=EmissionParameters,
69
+ metadata_class=EmissionMetadata,
70
+ extract_meta=extract_em_meta,
71
+ )
72
+ spatial.stac.export_catalog()
73
+ ```
74
+
75
+ The platform converts each source file into optimized formats, uploads them,
76
+ and indexes them. A re-run skips files whose content did not change.
77
+
78
+ Workflows live in their own repositories. See `WORKFLOWS.md` for more information.
79
+
80
+ ### Connecting to a platform
81
+
82
+ In multi-tenant deployments the connection details come from the manager
83
+ API. Staff log in with their user account once; the credentials are cached
84
+ locally and used in place of the shared service secret.
85
+
86
+ ```bash
87
+ spatial login # prompts for username and password, caches them
88
+ spatial whoami # show the identity the CLI would use
89
+ spatial platforms # list the platforms you can access
90
+ ```
91
+
92
+ The manager DSN's secret material stays in the local environment through
93
+ `{env:...}` placeholders. Imports are attributed to the logged-in user.
94
+
95
+ ### HTTP API
96
+
97
+ Web apps can read data over HTTP. A Litestar server lists hierarchies and
98
+ layers, renders map tiles through TiTiler, and streams vector tiles and
99
+ GeoJSON from object storage.
100
+
101
+ ```bash
102
+ curl -X PATCH http://<scse-spatial>/v1/{platform}/rasters/ \
103
+ -H "Content-Type: application/json" \
104
+ -d '{"hierarchy_id": 3, "parameters__contains": {"sector": "power"}}'
105
+ ```
106
+
107
+ Point an scse-app at the `/v1` or `/titiler` endpoints, or open `/docs` and
108
+ `/titiler/docs` for interactive API docs. See `HTTP_API.md` for more information.
109
+
110
+ ## Development Setup
111
+
112
+ ```bash
113
+ # Install Poetry, minimum version >=1.2 required
114
+ pipx install poetry
115
+
116
+ # You may have to reinitialize your shell at this point.
117
+ source ~/.bashrc
118
+
119
+ # Activate in-project virtualenvs
120
+ poetry config virtualenvs.in-project true
121
+
122
+ # Add dynamic versioning plugin
123
+ poetry self add "poetry-dynamic-versioning[plugin]"
124
+
125
+ # Install dependencies
126
+ # (using "--with dev" if dev dependencies should be installed as well)
127
+ poetry install --with dev
128
+
129
+ # Activate virtual environment
130
+ poetry shell
131
+
132
+ # Copy the template environment configuration
133
+ cp template.env .env
134
+
135
+ ```
136
+
@@ -0,0 +1,71 @@
1
+ spatial/__init__.py,sha256=O5fnfu4pycUsZ2OxD75O44Z2oYe7ypc-ErLOzxo3-iE,347
2
+ spatial/__main__.py,sha256=XkhyIe8pfOPor5k9qmd3xaLk0NX9k84P8552WluOVgs,66
3
+ spatial/_version.py,sha256=cRA43y3Jnp27uBqDtCORV6hlW2Ee4vFj6IKmlZcGaq0,52
4
+ spatial/cli/__init__.py,sha256=MoAVw81l00fM4iz5H23MDw8RTglGHE-lI6AZNaA7Fyc,441
5
+ spatial/cli/alembic.py,sha256=8_kD3luu-fZDj_-xE45m2B8mdfGtnoOmsGYUisK98WU,2580
6
+ spatial/cli/imports.py,sha256=8gP3XSnxxnPBavsabl_dwcZQ-uBG3oVPqzwYJ-GlltA,2908
7
+ spatial/cli/manager.py,sha256=esKbUoVHBECB5FPq74gJnJEP4h_xEcRDrQH-Y4_aWdk,5220
8
+ spatial/cli/server.py,sha256=Pds1WNRmZz4ZATMZdmHjzxhRkL9RrUxca1ob101byWw,452
9
+ spatial/cli/workflow.py,sha256=vaQTE9Mz07GKUV0Z8aFIV97p2TI-zOwEoydBXrFwV8U,11766
10
+ spatial/conf/__init__.py,sha256=l8BKzRD687hZpeXRDt-cYkNDMB6eiyL6a1vHPmIQPN0,132
11
+ spatial/conf/credentials.py,sha256=I92EOIJ9tbrCbxOdIoOmnGk5HtEx4sO7U5z8sRO1TW4,2312
12
+ spatial/conf/logging.json,sha256=OnlLx791TO87yj-n2Mh1fZp0vDeprgsPEpKg_bRZAfo,821
13
+ spatial/conf/platforms.py,sha256=3ctns1jl1jHZSGA6QXOclUyD1b0rWv6Fv9dx90eShuA,3052
14
+ spatial/conf/settings.py,sha256=feraDkHM_qvs503DnkMz7DustS4GR_KyKlg6pzMzqHc,14845
15
+ spatial/data/base/exceptions.py,sha256=mLE5tadlyFXu2LMqJEkUxKRhBaifseze3b8Rtsbxq54,913
16
+ spatial/data/base/filters.py,sha256=md1XJ-jdrKPBSEEZpTMqaZeeFpm5qOBcbRNiHXyKAv8,2052
17
+ spatial/data/base/hierarchy.py,sha256=8kPwVS0n9vQZwwtg0v99RLHq6Sdrd9OdB8LjvAu9Ue4,10584
18
+ spatial/data/base/pagination.py,sha256=dDFQX1eu2DuPp7mTd8rV_2grtIxl1UnBSvYgYAIEPO0,2300
19
+ spatial/data/base/service.py,sha256=WU3u6jR7Kax4GiTETHUszOQwhSgZeyIoQt99uK0Vo9o,5889
20
+ spatial/data/features/__init__.py,sha256=d8kqJ2X5CVaDU6oKUCvmaOWRPJp3bjcgqHZ2fxtet5Y,89
21
+ spatial/data/features/controller.py,sha256=P_r9HXSrvwwoT0jFbsGQbrK0oYqyHLyQtrjfP3n4u4g,3806
22
+ spatial/data/features/db.py,sha256=NRtHKg5jbvqUo_cQVw1Umrmdp3a_eN2oGcFy78MNUKI,4589
23
+ spatial/data/features/dto.py,sha256=A7ka9olxocceIBy9UgI1kBI0Xw8WhoxVwGUtsxY7rYk,2086
24
+ spatial/data/features/service.py,sha256=-KsBI3025Ivt2syvGkWmbU6o63HP9ZC-Y1CRNZUQnZw,26852
25
+ spatial/data/features/tiling.py,sha256=4Jcgfh3Q795-X7_hHo7uIO8PJWP2ixHvWnzDrRzbbOU,3397
26
+ spatial/data/file_storage.py,sha256=GFyfKE4G2RkOCsKOzSa0IKSl_3Ak55mI299ZiKXMirU,13259
27
+ spatial/data/hashing.py,sha256=mTLyANp2UDwP4E1hKD1h5T-sa_615OM-aEnM5WnZaDY,795
28
+ spatial/data/manifest/__init__.py,sha256=tpnbfmyLrF6rx8PuUKWkBTLv_hmqrrt0VT2_9vAU4po,187
29
+ spatial/data/manifest/db.py,sha256=ZeYNapN3lQM6Jh-e_uoFoblhOrr_HFXIxu6el0MsKMM,4041
30
+ spatial/data/rasters/__init__.py,sha256=Y_KQHT2XPQ8XcwV3l2yDQXHTyqEj-Qz-sw3znYc5HwY,192
31
+ spatial/data/rasters/controller.py,sha256=rVmyD_D7S5GcqhOYT_h4tAIXm_zl5nHDXEnIZlxlvV4,4970
32
+ spatial/data/rasters/db.py,sha256=hhVFkn99T7qFaFLHonbTa1fpXaEg-fRinNam23svgZY,7603
33
+ spatial/data/rasters/dto.py,sha256=JTdpX8V_UrffgsbKRF3pcaoygPKKKyGtxN2p1sZN6TQ,2882
34
+ spatial/data/rasters/service.py,sha256=l5xASgVkkDiZc77rYX3O14g3tmkGdvD32ZGLtFzTf-c,81385
35
+ spatial/data/rasters/tile.py,sha256=UuXL1O8pw_elU1a-xyiYvi-FI-VdUDehsy4zdBwFY7A,1265
36
+ spatial/data/stac/__init__.py,sha256=9TOLTj8K1JqNWYij4IlDIhr6UsskgXBnlr51KQqGois,64
37
+ spatial/data/stac/io.py,sha256=P_B8jWed7twigufVeooPQ0NjwbdeteiQhV9gi3AAa8A,2782
38
+ spatial/data/stac/service.py,sha256=MQR8MQaM2_KsdqSgDphHp_mHTKkWm1bCs2aqgFzh1oc,13417
39
+ spatial/db/__init__.py,sha256=Wib0RZ1o0mLdfJEshRaDhQv6LMTjG3MGDv9cxtBcnhQ,652
40
+ spatial/db/base.py,sha256=M2b3apFLfWjRj_rB8OXpbZTUcdrfEX1ZV-dYm82OeZw,981
41
+ spatial/db/migrations/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
42
+ spatial/db/migrations/env.py,sha256=Xwg6_Bf5rb-c161bzpyb0_FqgKlZzucB3jqiOz1VNYA,2695
43
+ spatial/db/migrations/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
44
+ spatial/db/migrations/versions/64901b988c97_initial.py,sha256=3C8i4gRa8re2_TMXQykrKGJXGn7ur-RRbivc9iLJMPk,7979
45
+ spatial/db/migrations/versions/7f3e2a1b9c4d_rename_url_columns_to_path.py,sha256=RGXR5LjSiisvTel_gA_2mLGAzo5EbxEvvMeh-Xg0jgg,3810
46
+ spatial/db/migrations/versions/8a2b3c4d5e6f_store_tile_params_in_metadata.py,sha256=8_N9X3GjKjZzl_R_tnGed3pWzMORnsxZcGE-HrBa-ZI,1332
47
+ spatial/db/migrations/versions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
+ spatial/db/migrations/versions/a1b2c3d4e5f6_drop_raster_step_columns.py,sha256=304_GfhcbmiSDUs6fahOnbHJm_hzDpPmI0vYPMzJRm0,1037
49
+ spatial/db/migrations/versions/b1c3d5e7f9a1_add_raster_hierarchy_projection_epsg.py,sha256=hLgbgoLzKVq6OQnG8Vxez3keYvZK12duHWuievgTFp8,896
50
+ spatial/db/migrations/versions/c5f3a4b8d9e7_drop_zarr_variable_and_feature_crs.py,sha256=l9BFVy6JceZkzwuTWnwsntPXorgbCjZr6rXY6EFqh3E,988
51
+ spatial/db/migrations/versions/d2e4f6a8b0c2_add_jsonb_gin_indexes.py,sha256=-dbQy0qbsdv84bdEGQ7rV29G3i9bLehi9sdujh_GWlA,1450
52
+ spatial/db/migrations/versions/e3f5a7c9d1b2_add_actor_attribution.py,sha256=r6TIiPnxZsCWEzBcnQj5qwm6vq7jtV6pBUCrmyc8EX0,1390
53
+ spatial/db/models.py,sha256=qYHUO1_3SNkzv7e0_lv9cOfDtOcGD_Dpvsr_9x6N_Zs,420
54
+ spatial/projections.py,sha256=uAr0vidrwo70ktE0bUKxNITid93rzOgw1LHhfV3lEzk,9896
55
+ spatial/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
56
+ spatial/server/__init__.py,sha256=W9gAiMQESiDlnfhcbU00PY4PL0gIun0Tb13KBlFMWEE,223
57
+ spatial/server/algorithm.py,sha256=tkJewk_DJEep0NjHJcVWqR1EbK5V6OBrkpDczrPZNec,1810
58
+ spatial/server/app.py,sha256=oZm3DWgfBJ9RntURG_R3DhJrOcae1jb1jZH6IiqDo64,6678
59
+ spatial/server/data_serve.py,sha256=ZkvKsR01CXYT-gOoITKJ0Fr55ZlqdmFwzkHVf0veUTA,4486
60
+ spatial/server/dependencies.py,sha256=z7eM94jwLqp6OShiA3NJE7NjK1Z-k6x7uRw5Coa41us,3208
61
+ spatial/server/exceptions.py,sha256=3l_MhJhpWPmeQC5_2CgGonhIzetgb0VS3JfHDsvxsas,1483
62
+ spatial/server/mounts.py,sha256=CcAdrWjq5x5KdQYtdLUXfsMMKeyqTf7DBT-cLets_18,2636
63
+ spatial/server/root.py,sha256=Z3MVLzY9tN39HbhvrqtbSq1jo86Mv-XLFolKELD_Su4,2062
64
+ spatial/server/schemas.py,sha256=EzAnUih-tkkStVL_a3UsNwzfI68UW69Msbutlyg_GS8,963
65
+ spatial/server/stac.py,sha256=1R9kyb4zirV_Y37fpMu0b4UafXAijO1DRxdNXfQR_kA,3199
66
+ spatial/server/titiler.py,sha256=2ZwCbiBnGSZNmYZ2K1BZ5jUDnITleO-D6vjgv0ZonLs,11820
67
+ spatial/spatial.py,sha256=bSqEq6zC98dl4T-Aky7vEzmO9WQbwtbrGPESqsOWFwk,10047
68
+ scse_spatial-1.0.0.dist-info/METADATA,sha256=HNT214BHTmQVQlXMFX00bCsRhFpDxOnCsLxovPZrtqI,4583
69
+ scse_spatial-1.0.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
70
+ scse_spatial-1.0.0.dist-info/entry_points.txt,sha256=k3HIUh-3rmsb-ExarlxTsuIt6Qc7JvQiFSWzAJtixiw,48
71
+ scse_spatial-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ spatial=spatial.__main__:app
3
+
spatial/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from ._version import __version__ as __version__
2
+ from ._version import __version_tuple__ as __version_tuple__
3
+ from .spatial import Spatial as Spatial
4
+ from .data.base.exceptions import Skip as Skip
5
+ from .data.features import Feature as Feature
6
+ from .data.rasters import RasterLayer as RasterLayer
7
+ from .data.rasters import TileParams as TileParams
spatial/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from spatial.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
spatial/_version.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "1.0.0"
2
+ __version_tuple__ = (1, 0, 0)
@@ -0,0 +1,19 @@
1
+ """Command line interface of the spatial data platform.
2
+
3
+ See the CLI help for usage information:
4
+
5
+ .. code:: bash
6
+
7
+ spatial --help
8
+ spatial server --help
9
+ """
10
+
11
+ import typer
12
+ from . import server, alembic, workflow, manager
13
+
14
+ app = typer.Typer()
15
+ app.add_typer(server.app, name="server")
16
+ app.add_typer(alembic.app, name="alembic")
17
+ app.add_typer(workflow.app)
18
+ app.add_typer(manager.app)
19
+ app.add_typer(manager.platforms_app, name="platforms")
spatial/cli/alembic.py ADDED
@@ -0,0 +1,85 @@
1
+ import typer
2
+ from toolkit.db.alembic import AlembicCli
3
+ from typing_extensions import Annotated
4
+
5
+ from spatial.conf import Settings
6
+ from spatial.conf.platforms import (
7
+ resolve_dsn_env_tokens,
8
+ resolve_spatial_dsn,
9
+ )
10
+ from spatial.db import get_alembic_controller
11
+
12
+ app = AlembicCli(
13
+ help="Manages alembic migrations on selected platforms via subcommands."
14
+ )
15
+
16
+
17
+ def collect_platforms(
18
+ settings: Settings,
19
+ *,
20
+ platform: list[str] | None,
21
+ manager: bool,
22
+ ) -> list[str]:
23
+ """Collect the DSNs of the selected command targets."""
24
+ candidates: list[str] = []
25
+ if settings.manager_url is not None and (platform or manager):
26
+ try:
27
+ platforms = settings.get_manager_platforms()
28
+ except ValueError as exc:
29
+ raise typer.BadParameter(str(exc)) from exc
30
+ if manager:
31
+ for p in platforms.list_spatial_platforms():
32
+ try:
33
+ dsn = resolve_spatial_dsn(p)
34
+ except ValueError:
35
+ continue # skip platforms with unresolvable dsn
36
+ candidates.append(dsn)
37
+
38
+ if platform is not None:
39
+ for name in platform:
40
+ platform_obj = platforms.get_spatial_platform(name)
41
+ dsn = resolve_spatial_dsn(platform_obj)
42
+ candidates.append(dsn)
43
+
44
+ if not candidates and settings.postgres_dsn is not None:
45
+ candidates.append(resolve_dsn_env_tokens(settings.postgres_dsn))
46
+
47
+ return candidates
48
+
49
+
50
+ @app.callback()
51
+ def alembic(
52
+ ctx: typer.Context,
53
+ platform: Annotated[
54
+ list[str] | None,
55
+ typer.Option(
56
+ "--platform",
57
+ "-p",
58
+ help="Use (a) platform name(s) as an alembic command target.",
59
+ ),
60
+ ] = None,
61
+ manager: Annotated[
62
+ bool,
63
+ typer.Option(
64
+ help="Use platforms from the manager api as alembic targets.",
65
+ ),
66
+ ] = False,
67
+ ) -> None:
68
+ """Run alembic commands on platform databases.
69
+ Use '--platform/-p' or '--manager' to select the
70
+ target databases. Falls back to
71
+ `SPATIAL_POSTGRES_DSN` if no platform is specified."""
72
+ settings = Settings()
73
+ settings.configure_logging()
74
+
75
+ dsns = collect_platforms(settings, platform=platform, manager=manager)
76
+
77
+ if len(dsns) == 0:
78
+ raise typer.BadParameter(
79
+ "No platforms found. Use '--platform/-p', '--manager', or set "
80
+ "`SPATIAL_POSTGRES_DSN`."
81
+ )
82
+
83
+ controllers = [get_alembic_controller(dsn) for dsn in dsns]
84
+ ctx.ensure_object(dict)
85
+ ctx.obj["controllers"] = controllers
spatial/cli/imports.py ADDED
@@ -0,0 +1,89 @@
1
+ """Resolve ``<module>:<name>`` references passed on the command line.
2
+
3
+ Workflows live in their own repositories, so the objects they hand to the
4
+ CLI (Pydantic models, extractor callables) cannot be imported by the CLI
5
+ itself. They are passed as ``<module>:<name>`` strings, resolved against
6
+ the working directory, and validated against an expected kind.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from importlib import import_module
13
+ from typing import Any, Callable, cast
14
+
15
+ import pydantic as pyd
16
+ import typer
17
+
18
+ ModelClassParser = Callable[[str], type[pyd.BaseModel]]
19
+ CallableParser = Callable[[str], Callable[..., Any]]
20
+
21
+
22
+ def import_member(reference: str) -> tuple[Any, str, str]:
23
+ """Import the member named by ``<module>:<name>``.
24
+
25
+ The working directory is added to ``sys.path`` so a workflow repository
26
+ can point at its own modules without being installed.
27
+
28
+ Returns
29
+ -------
30
+ tuple[Any, str, str]
31
+ The imported object, its name, and its module path.
32
+
33
+ Raises
34
+ ------
35
+ typer.BadParameter
36
+ If *reference* is malformed or the module cannot be imported.
37
+ """
38
+ if ":" not in reference:
39
+ raise typer.BadParameter(
40
+ f"'{reference}' is not a '<module>:<name>' reference."
41
+ )
42
+ module_name, name = reference.rsplit(":", 1)
43
+ if not module_name or not name:
44
+ raise typer.BadParameter(
45
+ f"'{reference}' is not a '<module>:<name>' reference."
46
+ )
47
+
48
+ cwd = "."
49
+ if cwd not in sys.path:
50
+ sys.path.insert(0, cwd)
51
+
52
+ try:
53
+ module = import_module(module_name)
54
+ except ImportError as exc:
55
+ raise typer.BadParameter(
56
+ f"Cannot import module '{module_name}' for reference '{reference}': {exc}"
57
+ ) from exc
58
+
59
+ member = getattr(module, name, None)
60
+ if member is None:
61
+ raise typer.BadParameter(
62
+ f"'{name}' does not exist in module '{module_name}'."
63
+ )
64
+ return member, name, module_name
65
+
66
+
67
+ def parse_model_class(reference: str) -> type[pyd.BaseModel]:
68
+ """Parse ``<module>:<name>`` into a Pydantic model class."""
69
+ member, name, module_name = import_member(reference)
70
+ if not isinstance(member, type) or not issubclass(member, pyd.BaseModel):
71
+ raise typer.BadParameter(
72
+ f"'{name}' in module '{module_name}' is not a Pydantic model."
73
+ )
74
+ return member
75
+
76
+
77
+ def parse_callable(reference: str) -> Callable[..., Any]:
78
+ """Parse ``<module>:<name>`` into a callable."""
79
+ member, name, module_name = import_member(reference)
80
+ if not callable(member):
81
+ raise typer.BadParameter(
82
+ f"'{name}' in module '{module_name}' is not callable."
83
+ )
84
+ return cast(Callable[..., Any], member)
85
+
86
+
87
+ def parse_csv_paths(value: str) -> list[str]:
88
+ """Parse a comma-separated list of paths, dropping empty entries."""
89
+ return [item.strip() for item in value.split(",") if item.strip()]
spatial/cli/manager.py ADDED
@@ -0,0 +1,161 @@
1
+ """Manager authentication and platform inspection commands.
2
+
3
+ Staff log in with their user account (``spatial login``); the credentials
4
+ are cached locally and used by the other commands instead of the shared
5
+ service secret. The connection details (the platform's spatial DSN) are
6
+ fetched from the manager; secret material inside it stays in the local
7
+ environment via ``{env:...}`` placeholders.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Annotated, Optional
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+ from toolkit.client.auth import ManagerAuth
19
+ from toolkit.exceptions import InvalidCredentials
20
+
21
+ from spatial.conf import Settings
22
+ from spatial.conf.credentials import Credential, CredentialStore
23
+
24
+ console = Console()
25
+
26
+ app = typer.Typer(help="Manager authentication and platform inspection.")
27
+ platforms_app = typer.Typer(
28
+ help="List platforms on the manager.", invoke_without_command=True
29
+ )
30
+
31
+ _USERINFO = re.compile(r"//(?P<user>[^:/@]+)(?::[^@]*)?@")
32
+
33
+
34
+ def _settings() -> Settings:
35
+ settings = Settings()
36
+ settings.configure_logging()
37
+ return settings
38
+
39
+
40
+ def _mask_dsn(dsn: str) -> str:
41
+ """Hide the password in a DSN, keeping the scheme, user and host."""
42
+ return _USERINFO.sub(r"//\g<user>:***@", dsn)
43
+
44
+
45
+ def _require_manager_url(settings: Settings) -> str:
46
+ if settings.manager_url is None:
47
+ raise typer.BadParameter("`SPATIAL_MANAGER_URL` must be set.")
48
+ return str(settings.manager_url)
49
+
50
+
51
+ @app.command("login")
52
+ def login(
53
+ username: Annotated[
54
+ Optional[str],
55
+ typer.Option("--username", "-u", help="Your manager username."),
56
+ ] = None,
57
+ ) -> None:
58
+ """Log in to the manager api and cache the credentials locally."""
59
+ settings = _settings()
60
+ url = _require_manager_url(settings)
61
+ if username is None:
62
+ username = typer.prompt("Username")
63
+ password = typer.prompt("Password", hide_input=True)
64
+
65
+ try:
66
+ ManagerAuth(username, password, url)
67
+ except InvalidCredentials:
68
+ raise typer.BadParameter("The credentials are not valid.")
69
+
70
+ store = CredentialStore(settings.auth_file)
71
+ store.save(Credential(manager_url=url, username=username, password=password))
72
+ console.print(
73
+ f"Logged in as [bold]{username}[/bold] on {url}. "
74
+ f"Credentials saved to {settings.auth_file}."
75
+ )
76
+
77
+
78
+ @app.command("logout")
79
+ def logout() -> None:
80
+ """Delete the locally cached manager credentials."""
81
+ settings = _settings()
82
+ store = CredentialStore(settings.auth_file)
83
+ if store.load() is None:
84
+ console.print("No cached credentials.")
85
+ return
86
+ if typer.confirm("Delete the cached credentials?"):
87
+ store.clear()
88
+ console.print("Logged out.")
89
+
90
+
91
+ @app.command("whoami")
92
+ def whoami() -> None:
93
+ """Show the manager identity the CLI would use."""
94
+ settings = _settings()
95
+ credential = CredentialStore(settings.auth_file).load()
96
+ if credential is not None:
97
+ console.print(f"User: {credential.username}")
98
+ console.print(f"Manager: {credential.manager_url}")
99
+ console.print("Auth: cached user credentials")
100
+ return
101
+ if settings.secret_hs256 is not None:
102
+ console.print("User: <service account>")
103
+ console.print(f"Manager: {settings.manager_url}")
104
+ console.print("Auth: self-signed service secret")
105
+ return
106
+ console.print("Not logged in. Run `spatial login`, or set `SPATIAL_SECRET_HS256`.")
107
+
108
+
109
+ def _list_platforms(settings: Settings) -> None:
110
+ rows = settings.get_manager_platforms().ixmp4.cached_list()
111
+ table = Table(title=f"Platforms on {settings.manager_url}")
112
+ table.add_column("slug")
113
+ table.add_column("name")
114
+ table.add_column("accessibility")
115
+ table.add_column("spatial")
116
+ table.add_column("dsn")
117
+ for platform in rows:
118
+ has_spatial = bool(getattr(platform, "spatial_dsn", None))
119
+ dsn_access = (
120
+ "visible" if getattr(platform, "is_mutable", False) else "hidden"
121
+ )
122
+ table.add_row(
123
+ platform.slug,
124
+ platform.name,
125
+ str(platform.accessibility),
126
+ "yes" if has_spatial else "no",
127
+ dsn_access if has_spatial else "-",
128
+ )
129
+ console.print(table)
130
+
131
+
132
+ @platforms_app.callback()
133
+ def platforms(
134
+ ctx: typer.Context,
135
+ ) -> None:
136
+ """List the platforms you can access on the manager."""
137
+ if ctx.invoked_subcommand is not None:
138
+ return
139
+ _list_platforms(_settings())
140
+
141
+
142
+ @platforms_app.command("show")
143
+ def platforms_show(
144
+ slug: Annotated[str, typer.Argument(help="Platform slug.")],
145
+ ) -> None:
146
+ """Show the connection details of one platform."""
147
+ settings = _settings()
148
+ platform = settings.get_manager_platforms().get_spatial_platform(slug)
149
+
150
+ console.print(f"[bold]{platform.name}[/bold] ({platform.slug})")
151
+ console.print(f"accessibility: {platform.accessibility}")
152
+ if getattr(platform, "is_mutable", False):
153
+ console.print(f"spatial DSN: {_mask_dsn(str(platform.spatial_dsn))}")
154
+ else:
155
+ console.print(
156
+ "spatial DSN: hidden (you are not a manager of this platform)"
157
+ )
158
+ console.print(f"spatial URL: {platform.spatial_dsn}")
159
+
160
+
161
+ __all__ = ["app", "platforms_app"]
spatial/cli/server.py ADDED
@@ -0,0 +1,24 @@
1
+ import typer
2
+ import uvicorn
3
+
4
+
5
+ app = typer.Typer(help="CLI for starting the spatial server.")
6
+
7
+
8
+ @app.command()
9
+ def start(
10
+ host: str = typer.Option(
11
+ "127.0.0.1",
12
+ help="The hostname to bind to.",
13
+ ),
14
+ port: int = typer.Option(
15
+ 9000,
16
+ help="Requested server port.",
17
+ ),
18
+ ) -> None:
19
+ """Start the spatial server."""
20
+ uvicorn.run(
21
+ "spatial.server.app:app",
22
+ host=host,
23
+ port=port,
24
+ )