geolibre-wasm 0.4.0__tar.gz
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.
- geolibre_wasm-0.4.0/.gitignore +20 -0
- geolibre_wasm-0.4.0/PKG-INFO +108 -0
- geolibre_wasm-0.4.0/README.md +90 -0
- geolibre_wasm-0.4.0/pyproject.toml +28 -0
- geolibre_wasm-0.4.0/src/geolibre_wasm/__init__.py +40 -0
- geolibre_wasm-0.4.0/src/geolibre_wasm/_core.py +220 -0
- geolibre_wasm-0.4.0/tests/test_smoke.py +65 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/target
|
|
2
|
+
node_modules
|
|
3
|
+
# Local-only drafts and notes (e.g. LinkedIn announcements)
|
|
4
|
+
/private/
|
|
5
|
+
# Build artifacts staged into npm/ by build.sh (regenerated on every build)
|
|
6
|
+
npm/geolibre-cli.wasm
|
|
7
|
+
npm/geolibre_wasm.js
|
|
8
|
+
npm/geolibre_wasm_bg.wasm
|
|
9
|
+
npm/geolibre_wasm.d.ts
|
|
10
|
+
npm/geolibre_wasm_bg.wasm.d.ts
|
|
11
|
+
npm/snippets/
|
|
12
|
+
*.wasm-opt.wasm
|
|
13
|
+
# Python wrapper build artifacts
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
python/build/
|
|
17
|
+
python/dist/
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
# Cargo.lock is committed: this workspace ships a binary, and the lock pins the
|
|
20
|
+
# exact whitebox_next_gen fork commit for reproducible WASI builds.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geolibre-wasm
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Run the GeoLibre / whitebox_next_gen geospatial tool suite (WASM/WASI) from Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/opengeos/geolibre-rust
|
|
6
|
+
Project-URL: Repository, https://github.com/opengeos/geolibre-rust
|
|
7
|
+
Project-URL: Issues, https://github.com/opengeos/geolibre-rust/issues
|
|
8
|
+
Author-email: Qiusheng Wu <giswqs@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: geolibre,geoparquet,geospatial,gis,raster,vector,wasi,wasm,whitebox
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: wasmtime>=20
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# geolibre-wasm (Python)
|
|
20
|
+
|
|
21
|
+
Run the [`geolibre-rust`](https://github.com/opengeos/geolibre-rust) geospatial
|
|
22
|
+
tool suite (the `whitebox_next_gen` tools plus GeoLibre's own) from Python. The
|
|
23
|
+
tools are a single WebAssembly (WASI) module executed in-process via
|
|
24
|
+
[`wasmtime`](https://github.com/bytecodealliance/wasmtime-py), so there is **no
|
|
25
|
+
native install, no GDAL, and no server** — just `pip install`.
|
|
26
|
+
|
|
27
|
+
This mirrors the JavaScript `geolibre-wasm/tools` API (`list_tools`,
|
|
28
|
+
`list_manifests`, `run_tool`), so the two stay in sync.
|
|
29
|
+
|
|
30
|
+
> The import package is `geolibre_wasm` (the distribution is `geolibre-wasm`),
|
|
31
|
+
> matching the npm package name and avoiding a clash with the separate
|
|
32
|
+
> `geolibre` application package.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install geolibre-wasm
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
On first use the runtime (`geolibre-cli.wasm`, ~20 MB) is downloaded from the
|
|
41
|
+
matching GitHub release and cached under `~/.cache/geolibre/`. To use a local
|
|
42
|
+
copy instead, set `GEOLIBRE_WASM=/path/to/geolibre-cli.wasm` or pass
|
|
43
|
+
`wasm_path=` to any call.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
Inputs are passed as `bytes` under `/work`; the tool reads/writes there and any
|
|
48
|
+
new files come back as `bytes`.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import geolibre_wasm as gl
|
|
52
|
+
|
|
53
|
+
# Discover tools (each manifest carries a "source": "geolibre" | "whitebox")
|
|
54
|
+
tools = gl.list_tools()
|
|
55
|
+
manifests = gl.list_manifests()
|
|
56
|
+
|
|
57
|
+
# Raster: compute slope from a DEM
|
|
58
|
+
dem = open("dem.tif", "rb").read()
|
|
59
|
+
res = gl.run_tool(
|
|
60
|
+
"slope",
|
|
61
|
+
args=["--input=/work/dem.tif", "--output=/work/slope.tif", "--units=degrees"],
|
|
62
|
+
input={"dem.tif": dem},
|
|
63
|
+
)
|
|
64
|
+
assert res.exit_code == 0
|
|
65
|
+
open("slope.tif", "wb").write(res.files["slope.tif"])
|
|
66
|
+
|
|
67
|
+
# Reproject (warp) to a target EPSG
|
|
68
|
+
res = gl.run_tool(
|
|
69
|
+
"reproject_raster",
|
|
70
|
+
args=["--input=/work/dem.tif", "--output=/work/wgs84.tif", "--epsg=4326"],
|
|
71
|
+
input={"dem.tif": dem},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Vector: GeoJSON -> GeoParquet (Hilbert-sorted, bbox covering, ZSTD by default)
|
|
75
|
+
gj = open("cities.geojson", "rb").read()
|
|
76
|
+
res = gl.run_tool(
|
|
77
|
+
"write_geoparquet",
|
|
78
|
+
args=["--input=/work/in.geojson", "--output=/work/out.parquet"],
|
|
79
|
+
input={"in.geojson": gj},
|
|
80
|
+
)
|
|
81
|
+
open("cities.parquet", "wb").write(res.files["out.parquet"])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Tools that write a directory tree (e.g. `raster_to_tiles`) return nested keys:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
res = gl.run_tool(
|
|
88
|
+
"raster_to_tiles",
|
|
89
|
+
args=["--input=/work/dem.tif", "--output_dir=/work/tiles", "--min_zoom=16", "--max_zoom=18"],
|
|
90
|
+
input={"dem.tif": dem},
|
|
91
|
+
)
|
|
92
|
+
for path, data in res.files.items():
|
|
93
|
+
# e.g. "tiles/16/9559/32767.png"
|
|
94
|
+
...
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
- `list_tools(wasm_path=None) -> list[str]`
|
|
100
|
+
- `list_manifests(wasm_path=None) -> list[dict]`
|
|
101
|
+
- `run_tool(tool, args=None, input=None, wasm_path=None) -> ToolResult`
|
|
102
|
+
- `ToolResult(exit_code: int, stdout: list[str], files: dict[str, bytes])`
|
|
103
|
+
- `runtime_path(wasm_path=None) -> str` — resolve the runtime (explicit > `GEOLIBRE_WASM` > cached download)
|
|
104
|
+
- `download_runtime(dest=None) -> str` — fetch the runtime ahead of time
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# geolibre-wasm (Python)
|
|
2
|
+
|
|
3
|
+
Run the [`geolibre-rust`](https://github.com/opengeos/geolibre-rust) geospatial
|
|
4
|
+
tool suite (the `whitebox_next_gen` tools plus GeoLibre's own) from Python. The
|
|
5
|
+
tools are a single WebAssembly (WASI) module executed in-process via
|
|
6
|
+
[`wasmtime`](https://github.com/bytecodealliance/wasmtime-py), so there is **no
|
|
7
|
+
native install, no GDAL, and no server** — just `pip install`.
|
|
8
|
+
|
|
9
|
+
This mirrors the JavaScript `geolibre-wasm/tools` API (`list_tools`,
|
|
10
|
+
`list_manifests`, `run_tool`), so the two stay in sync.
|
|
11
|
+
|
|
12
|
+
> The import package is `geolibre_wasm` (the distribution is `geolibre-wasm`),
|
|
13
|
+
> matching the npm package name and avoiding a clash with the separate
|
|
14
|
+
> `geolibre` application package.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install geolibre-wasm
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
On first use the runtime (`geolibre-cli.wasm`, ~20 MB) is downloaded from the
|
|
23
|
+
matching GitHub release and cached under `~/.cache/geolibre/`. To use a local
|
|
24
|
+
copy instead, set `GEOLIBRE_WASM=/path/to/geolibre-cli.wasm` or pass
|
|
25
|
+
`wasm_path=` to any call.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Inputs are passed as `bytes` under `/work`; the tool reads/writes there and any
|
|
30
|
+
new files come back as `bytes`.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import geolibre_wasm as gl
|
|
34
|
+
|
|
35
|
+
# Discover tools (each manifest carries a "source": "geolibre" | "whitebox")
|
|
36
|
+
tools = gl.list_tools()
|
|
37
|
+
manifests = gl.list_manifests()
|
|
38
|
+
|
|
39
|
+
# Raster: compute slope from a DEM
|
|
40
|
+
dem = open("dem.tif", "rb").read()
|
|
41
|
+
res = gl.run_tool(
|
|
42
|
+
"slope",
|
|
43
|
+
args=["--input=/work/dem.tif", "--output=/work/slope.tif", "--units=degrees"],
|
|
44
|
+
input={"dem.tif": dem},
|
|
45
|
+
)
|
|
46
|
+
assert res.exit_code == 0
|
|
47
|
+
open("slope.tif", "wb").write(res.files["slope.tif"])
|
|
48
|
+
|
|
49
|
+
# Reproject (warp) to a target EPSG
|
|
50
|
+
res = gl.run_tool(
|
|
51
|
+
"reproject_raster",
|
|
52
|
+
args=["--input=/work/dem.tif", "--output=/work/wgs84.tif", "--epsg=4326"],
|
|
53
|
+
input={"dem.tif": dem},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Vector: GeoJSON -> GeoParquet (Hilbert-sorted, bbox covering, ZSTD by default)
|
|
57
|
+
gj = open("cities.geojson", "rb").read()
|
|
58
|
+
res = gl.run_tool(
|
|
59
|
+
"write_geoparquet",
|
|
60
|
+
args=["--input=/work/in.geojson", "--output=/work/out.parquet"],
|
|
61
|
+
input={"in.geojson": gj},
|
|
62
|
+
)
|
|
63
|
+
open("cities.parquet", "wb").write(res.files["out.parquet"])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Tools that write a directory tree (e.g. `raster_to_tiles`) return nested keys:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
res = gl.run_tool(
|
|
70
|
+
"raster_to_tiles",
|
|
71
|
+
args=["--input=/work/dem.tif", "--output_dir=/work/tiles", "--min_zoom=16", "--max_zoom=18"],
|
|
72
|
+
input={"dem.tif": dem},
|
|
73
|
+
)
|
|
74
|
+
for path, data in res.files.items():
|
|
75
|
+
# e.g. "tiles/16/9559/32767.png"
|
|
76
|
+
...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
- `list_tools(wasm_path=None) -> list[str]`
|
|
82
|
+
- `list_manifests(wasm_path=None) -> list[dict]`
|
|
83
|
+
- `run_tool(tool, args=None, input=None, wasm_path=None) -> ToolResult`
|
|
84
|
+
- `ToolResult(exit_code: int, stdout: list[str], files: dict[str, bytes])`
|
|
85
|
+
- `runtime_path(wasm_path=None) -> str` — resolve the runtime (explicit > `GEOLIBRE_WASM` > cached download)
|
|
86
|
+
- `download_runtime(dest=None) -> str` — fetch the runtime ahead of time
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "geolibre-wasm"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Run the GeoLibre / whitebox_next_gen geospatial tool suite (WASM/WASI) from Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Qiusheng Wu", email = "giswqs@gmail.com" }]
|
|
13
|
+
keywords = ["geospatial", "gis", "wasm", "wasi", "raster", "vector", "geoparquet", "whitebox", "geolibre"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["wasmtime>=20"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/opengeos/geolibre-rust"
|
|
24
|
+
Repository = "https://github.com/opengeos/geolibre-rust"
|
|
25
|
+
Issues = "https://github.com/opengeos/geolibre-rust/issues"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/geolibre_wasm"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""GeoLibre: run the whitebox_next_gen + GeoLibre geospatial tool suite from Python.
|
|
2
|
+
|
|
3
|
+
The tools are compiled to a single WebAssembly (WASI) module and executed
|
|
4
|
+
in-process via wasmtime, so there is no native install, GDAL, or server. The API
|
|
5
|
+
mirrors the JavaScript ``geolibre-wasm/tools`` package.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> import geolibre_wasm as gl
|
|
9
|
+
>>> dem = open("dem.tif", "rb").read()
|
|
10
|
+
>>> result = gl.run_tool(
|
|
11
|
+
... "slope",
|
|
12
|
+
... args=["--input=/work/dem.tif", "--output=/work/slope.tif", "--units=degrees"],
|
|
13
|
+
... input={"dem.tif": dem},
|
|
14
|
+
... )
|
|
15
|
+
>>> result.exit_code
|
|
16
|
+
0
|
|
17
|
+
>>> open("slope.tif", "wb").write(result.files["slope.tif"])
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ._core import (
|
|
21
|
+
RUNTIME_VERSION,
|
|
22
|
+
ToolResult,
|
|
23
|
+
download_runtime,
|
|
24
|
+
list_manifests,
|
|
25
|
+
list_tools,
|
|
26
|
+
run_tool,
|
|
27
|
+
runtime_path,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ToolResult",
|
|
32
|
+
"RUNTIME_VERSION",
|
|
33
|
+
"list_tools",
|
|
34
|
+
"list_manifests",
|
|
35
|
+
"run_tool",
|
|
36
|
+
"runtime_path",
|
|
37
|
+
"download_runtime",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Run the GeoLibre WASI tool runner from Python via an in-process wasmtime.
|
|
2
|
+
|
|
3
|
+
The tools are the single ``geolibre-cli.wasm`` (a ``wasm32-wasip1`` module). Each
|
|
4
|
+
call runs it over a private temporary directory preopened as ``/work``: input
|
|
5
|
+
files are written there, the tool reads/writes via ordinary ``std::fs``, and any
|
|
6
|
+
new files are returned as ``bytes``. This mirrors the JavaScript ``tools.mjs``
|
|
7
|
+
API (``list_tools`` / ``list_manifests`` / ``run_tool``) so the two stay in sync.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import tempfile
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, List, Mapping, Optional, Sequence, Union
|
|
20
|
+
|
|
21
|
+
import wasmtime
|
|
22
|
+
|
|
23
|
+
#: Release whose ``geolibre-cli.wasm`` asset this wrapper downloads by default.
|
|
24
|
+
#: Kept in sync with the package version's ``vMAJOR.MINOR.PATCH`` tag.
|
|
25
|
+
RUNTIME_VERSION = "v0.4.0"
|
|
26
|
+
_ASSET = f"geolibre-cli-{RUNTIME_VERSION}.wasm"
|
|
27
|
+
_RELEASE_URL = (
|
|
28
|
+
"https://github.com/opengeos/geolibre-rust/releases/download/"
|
|
29
|
+
f"{RUNTIME_VERSION}/{_ASSET}"
|
|
30
|
+
)
|
|
31
|
+
_STDOUT_CAPTURE = ".geolibre-stdout"
|
|
32
|
+
|
|
33
|
+
PathLike = Union[str, os.PathLike]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ToolResult:
|
|
38
|
+
"""Result of running a tool.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
exit_code: Process exit code (0 = success).
|
|
42
|
+
stdout: Captured stdout/stderr lines.
|
|
43
|
+
files: New files the tool wrote, keyed by path relative to ``/work``.
|
|
44
|
+
Tools that write a tree (e.g. ``raster_to_tiles``) use nested keys
|
|
45
|
+
such as ``"tiles/15/4779/16383.png"``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
exit_code: int
|
|
49
|
+
stdout: List[str]
|
|
50
|
+
files: Dict[str, bytes]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_engine: Optional[wasmtime.Engine] = None
|
|
54
|
+
_module_cache: Dict[str, "wasmtime.Module"] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_engine() -> "wasmtime.Engine":
|
|
58
|
+
global _engine
|
|
59
|
+
if _engine is None:
|
|
60
|
+
_engine = wasmtime.Engine()
|
|
61
|
+
return _engine
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cache_path() -> Path:
|
|
65
|
+
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
66
|
+
return Path(base) / "geolibre" / _ASSET
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def download_runtime(dest: Optional[PathLike] = None) -> str:
|
|
70
|
+
"""Download the ``geolibre-cli.wasm`` runtime from the GitHub release.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
dest: Where to write the file. Defaults to the per-user cache
|
|
74
|
+
(``$XDG_CACHE_HOME/geolibre/`` or ``~/.cache/geolibre/``).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The path to the downloaded runtime.
|
|
78
|
+
"""
|
|
79
|
+
target = Path(dest) if dest is not None else _cache_path()
|
|
80
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
tmp = target.with_name(target.name + ".download")
|
|
82
|
+
urllib.request.urlretrieve(_RELEASE_URL, tmp) # noqa: S310 (trusted release URL)
|
|
83
|
+
tmp.replace(target)
|
|
84
|
+
return str(target)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def runtime_path(wasm_path: Optional[PathLike] = None) -> str:
|
|
88
|
+
"""Resolve the runtime ``.wasm`` to use.
|
|
89
|
+
|
|
90
|
+
Resolution order: the explicit ``wasm_path`` argument, the ``GEOLIBRE_WASM``
|
|
91
|
+
environment variable, then the cached download (fetched on first use).
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
wasm_path: Explicit path to a ``geolibre-cli.wasm``; takes precedence.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Filesystem path to the runtime module.
|
|
98
|
+
"""
|
|
99
|
+
if wasm_path is not None:
|
|
100
|
+
return str(wasm_path)
|
|
101
|
+
env = os.environ.get("GEOLIBRE_WASM")
|
|
102
|
+
if env:
|
|
103
|
+
return env
|
|
104
|
+
cache = _cache_path()
|
|
105
|
+
if not cache.exists():
|
|
106
|
+
download_runtime(cache)
|
|
107
|
+
return str(cache)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_module(path: str) -> "wasmtime.Module":
|
|
111
|
+
module = _module_cache.get(path)
|
|
112
|
+
if module is None:
|
|
113
|
+
module = wasmtime.Module.from_file(_get_engine(), path)
|
|
114
|
+
_module_cache[path] = module
|
|
115
|
+
return module
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _exec(
|
|
119
|
+
argv: Sequence[str],
|
|
120
|
+
inputs: Optional[Mapping[str, bytes]] = None,
|
|
121
|
+
wasm_path: Optional[PathLike] = None,
|
|
122
|
+
) -> ToolResult:
|
|
123
|
+
inputs = inputs or {}
|
|
124
|
+
module = _load_module(runtime_path(wasm_path))
|
|
125
|
+
engine = _get_engine()
|
|
126
|
+
work = Path(tempfile.mkdtemp(prefix="geolibre-"))
|
|
127
|
+
try:
|
|
128
|
+
for name, data in inputs.items():
|
|
129
|
+
dest = work / name
|
|
130
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
dest.write_bytes(bytes(data))
|
|
132
|
+
|
|
133
|
+
stdout_file = work / _STDOUT_CAPTURE
|
|
134
|
+
store = wasmtime.Store(engine)
|
|
135
|
+
wasi = wasmtime.WasiConfig()
|
|
136
|
+
wasi.argv = ["geolibre", *argv]
|
|
137
|
+
wasi.preopen_dir(str(work), "/work")
|
|
138
|
+
# Merge stdout + stderr into one capture, matching tools.mjs.
|
|
139
|
+
wasi.stdout_file = str(stdout_file)
|
|
140
|
+
wasi.stderr_file = str(stdout_file)
|
|
141
|
+
store.set_wasi(wasi)
|
|
142
|
+
|
|
143
|
+
linker = wasmtime.Linker(engine)
|
|
144
|
+
linker.define_wasi()
|
|
145
|
+
instance = linker.instantiate(store, module)
|
|
146
|
+
start = instance.exports(store)["_start"]
|
|
147
|
+
|
|
148
|
+
exit_code = 0
|
|
149
|
+
try:
|
|
150
|
+
start(store) # WASI returns by calling proc_exit -> ExitTrap
|
|
151
|
+
except wasmtime.ExitTrap as exit_trap:
|
|
152
|
+
exit_code = exit_trap.code
|
|
153
|
+
|
|
154
|
+
stdout: List[str] = []
|
|
155
|
+
if stdout_file.exists():
|
|
156
|
+
stdout = stdout_file.read_text(errors="replace").splitlines()
|
|
157
|
+
|
|
158
|
+
input_names = set(inputs)
|
|
159
|
+
files: Dict[str, bytes] = {}
|
|
160
|
+
for path in sorted(work.rglob("*")):
|
|
161
|
+
if not path.is_file():
|
|
162
|
+
continue
|
|
163
|
+
rel = path.relative_to(work).as_posix()
|
|
164
|
+
if rel == _STDOUT_CAPTURE or rel in input_names:
|
|
165
|
+
continue
|
|
166
|
+
files[rel] = path.read_bytes()
|
|
167
|
+
|
|
168
|
+
return ToolResult(exit_code=exit_code, stdout=stdout, files=files)
|
|
169
|
+
finally:
|
|
170
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def list_tools(wasm_path: Optional[PathLike] = None) -> List[str]:
|
|
174
|
+
"""List every available tool id.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
wasm_path: Optional explicit runtime path (see :func:`runtime_path`).
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The tool ids, one per registered tool.
|
|
181
|
+
"""
|
|
182
|
+
result = _exec(["list"], wasm_path=wasm_path)
|
|
183
|
+
return [line.strip() for line in result.stdout if line.strip()]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def list_manifests(wasm_path: Optional[PathLike] = None) -> List[dict]:
|
|
187
|
+
"""Fetch every tool manifest (id, parameters, category, provenance, ...).
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
wasm_path: Optional explicit runtime path (see :func:`runtime_path`).
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
A list of manifest dicts, each including a ``"source"`` field of
|
|
194
|
+
``"geolibre"`` or ``"whitebox"``.
|
|
195
|
+
"""
|
|
196
|
+
result = _exec(["manifests"], wasm_path=wasm_path)
|
|
197
|
+
return json.loads("".join(result.stdout))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def run_tool(
|
|
201
|
+
tool: str,
|
|
202
|
+
args: Optional[Sequence[str]] = None,
|
|
203
|
+
input: Optional[Mapping[str, bytes]] = None,
|
|
204
|
+
wasm_path: Optional[PathLike] = None,
|
|
205
|
+
) -> ToolResult:
|
|
206
|
+
"""Run one tool over an in-memory ``/work`` filesystem.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
tool: Tool id, e.g. ``"slope"`` (see :func:`list_tools`).
|
|
210
|
+
args: CLI args, e.g.
|
|
211
|
+
``["--input=/work/dem.tif", "--output=/work/slope.tif", "--units=degrees"]``.
|
|
212
|
+
input: Files placed under ``/work`` before the run, keyed by filename
|
|
213
|
+
(values are ``bytes``).
|
|
214
|
+
wasm_path: Optional explicit runtime path (see :func:`runtime_path`).
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
A :class:`ToolResult` with the exit code, captured output, and any new
|
|
218
|
+
files the tool wrote (keyed by path relative to ``/work``).
|
|
219
|
+
"""
|
|
220
|
+
return _exec([tool, *(args or [])], inputs=input, wasm_path=wasm_path)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Smoke tests for the geolibre Python wrapper.
|
|
2
|
+
|
|
3
|
+
These run the real WASI tool runner. Point ``GEOLIBRE_WASM`` at a local
|
|
4
|
+
``geolibre-cli.wasm`` to avoid a network download (the build stages one into
|
|
5
|
+
``npm/``); otherwise the runtime is fetched from the GitHub release on first use.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import geolibre_wasm as geolibre
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_list_tools():
|
|
16
|
+
tools = geolibre.list_tools()
|
|
17
|
+
assert len(tools) > 100
|
|
18
|
+
# The GeoLibre-authored tools are registered alongside whitebox's.
|
|
19
|
+
for tool in ("slope", "reproject_raster", "write_geoparquet", "read_geoparquet"):
|
|
20
|
+
assert tool in tools
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_manifests_carry_provenance():
|
|
24
|
+
manifests = geolibre.list_manifests()
|
|
25
|
+
by_id = {m["id"]: m for m in manifests}
|
|
26
|
+
assert by_id["reproject_raster"]["source"] == "geolibre"
|
|
27
|
+
assert by_id["slope"]["source"] == "whitebox"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_geoparquet_roundtrip():
|
|
31
|
+
geojson = json.dumps(
|
|
32
|
+
{
|
|
33
|
+
"type": "FeatureCollection",
|
|
34
|
+
"features": [
|
|
35
|
+
{
|
|
36
|
+
"type": "Feature",
|
|
37
|
+
"properties": {"name": "A", "val": 1},
|
|
38
|
+
"geometry": {"type": "Point", "coordinates": [-122.3, 47.6]},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"type": "Feature",
|
|
42
|
+
"properties": {"name": "B", "val": 2},
|
|
43
|
+
"geometry": {"type": "Point", "coordinates": [-122.4, 47.7]},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
).encode()
|
|
48
|
+
|
|
49
|
+
written = geolibre.run_tool(
|
|
50
|
+
"write_geoparquet",
|
|
51
|
+
args=["--input=/work/in.geojson", "--output=/work/out.parquet"],
|
|
52
|
+
input={"in.geojson": geojson},
|
|
53
|
+
)
|
|
54
|
+
assert written.exit_code == 0
|
|
55
|
+
parquet = written.files["out.parquet"]
|
|
56
|
+
assert parquet[:4] == b"PAR1"
|
|
57
|
+
|
|
58
|
+
back = geolibre.run_tool(
|
|
59
|
+
"read_geoparquet",
|
|
60
|
+
args=["--input=/work/x.parquet", "--output=/work/back.geojson"],
|
|
61
|
+
input={"x.parquet": parquet},
|
|
62
|
+
)
|
|
63
|
+
assert back.exit_code == 0
|
|
64
|
+
fc = json.loads(back.files["back.geojson"])
|
|
65
|
+
assert len(fc["features"]) == 2
|