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.
@@ -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