earthforge-cli 0.1.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,31 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ *.whl
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # IDE
15
+ .vscode/
16
+ .idea/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Testing
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+ .mypy_cache/
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
29
+
30
+ # Claude
31
+ .claude/
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: earthforge-cli
3
+ Version: 0.1.0
4
+ Summary: EarthForge CLI — thin Typer dispatch layer for cloud-native geospatial operations
5
+ License-Expression: GPL-3.0-or-later
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: earthforge-core>=0.1.0
8
+ Requires-Dist: textual>=0.52
9
+ Requires-Dist: typer>=0.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # earthforge-cli
13
+
14
+ Typer-based CLI for EarthForge — thin dispatch layer with no business logic.
15
+
16
+ See the [main README](../../README.md) for project documentation.
@@ -0,0 +1,5 @@
1
+ # earthforge-cli
2
+
3
+ Typer-based CLI for EarthForge — thin dispatch layer with no business logic.
4
+
5
+ See the [main README](../../README.md) for project documentation.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "earthforge-cli"
7
+ version = "0.1.0"
8
+ description = "EarthForge CLI — thin Typer dispatch layer for cloud-native geospatial operations"
9
+ readme = "README.md"
10
+ license = "GPL-3.0-or-later"
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "earthforge-core>=0.1.0",
14
+ "typer>=0.9",
15
+ "textual>=0.52",
16
+ ]
17
+
18
+ [project.scripts]
19
+ earthforge = "earthforge.cli.main:app"
20
+ tf = "earthforge.cli.main:app"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/earthforge"]
@@ -0,0 +1,6 @@
1
+ """EarthForge CLI — thin dispatch layer for cloud-native geospatial operations.
2
+
3
+ This package contains NO business logic. Every command handler parses arguments,
4
+ calls an async library function via ``asyncio.run()``, and passes the result to
5
+ the output renderer. That's it.
6
+ """
@@ -0,0 +1,5 @@
1
+ """EarthForge CLI command modules.
2
+
3
+ Each module in this package defines one or more Typer command handlers.
4
+ Handlers are thin: parse args → call async library function → render output.
5
+ """
@@ -0,0 +1,215 @@
1
+ """EarthForge ``bench`` command group — performance benchmarks.
2
+
3
+ Measures I/O and query performance for EarthForge operations and reports
4
+ timing, throughput, and comparison baselines. Results are emitted as a
5
+ structured table or JSON for CI performance tracking.
6
+
7
+ Available benchmarks
8
+ --------------------
9
+ vector-query
10
+ Compares GeoParquet bbox query (with predicate pushdown) against a
11
+ full sequential scan of the same data. Reports rows returned, elapsed
12
+ time, and estimated data read ratio.
13
+
14
+ raster-info
15
+ Measures round-trip time for COG header reads via HTTP range requests.
16
+ Reports bytes transferred and time to first metadata.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import time
23
+ from typing import Any
24
+
25
+ import typer
26
+ from pydantic import BaseModel, Field
27
+
28
+ from earthforge.core.output import render_to_console
29
+
30
+ app = typer.Typer(
31
+ name="bench",
32
+ help="Run EarthForge performance benchmarks.",
33
+ no_args_is_help=True,
34
+ )
35
+
36
+
37
+ class BenchResult(BaseModel):
38
+ """Structured result for a single benchmark run.
39
+
40
+ Attributes:
41
+ benchmark: Name of the benchmark (e.g. ``"vector-query"``).
42
+ description: Human-readable description of what was measured.
43
+ runs: Number of timed repetitions.
44
+ results: Per-run timing and metric data.
45
+ summary: Aggregated summary (min/mean/max elapsed, comparison).
46
+ """
47
+
48
+ benchmark: str = Field(title="Benchmark")
49
+ description: str = Field(title="Description")
50
+ runs: int = Field(title="Runs")
51
+ results: list[dict[str, Any]] = Field(default_factory=list, title="Results")
52
+ summary: dict[str, Any] = Field(default_factory=dict, title="Summary")
53
+
54
+
55
+ def _stats(times: list[float]) -> dict[str, float]:
56
+ """Compute min/mean/max for a list of elapsed times."""
57
+ return {
58
+ "min_s": round(min(times), 4),
59
+ "mean_s": round(sum(times) / len(times), 4),
60
+ "max_s": round(max(times), 4),
61
+ }
62
+
63
+
64
+ def vector_query(
65
+ ctx: typer.Context,
66
+ source: str = typer.Argument(
67
+ help="Path to a GeoParquet file to benchmark against.",
68
+ ),
69
+ bbox: str = typer.Option(
70
+ "-85.5,37.0,-84.0,38.5",
71
+ "--bbox",
72
+ help="Bounding box for the query benchmark: west,south,east,north.",
73
+ ),
74
+ runs: int = typer.Option(
75
+ 3,
76
+ "--runs",
77
+ "-n",
78
+ help="Number of timed repetitions per method.",
79
+ ),
80
+ ) -> None:
81
+ """Benchmark GeoParquet bbox query — predicate pushdown vs. full scan.
82
+
83
+ Runs the same spatial query twice: once using EarthForge's predicate
84
+ pushdown path (reads only intersecting row groups) and once by loading
85
+ the entire file and filtering in Python. Reports timing and data read
86
+ ratios for both methods.
87
+ """
88
+ from earthforge.cli.main import get_state
89
+
90
+ state = get_state(ctx)
91
+
92
+ parts = [float(v.strip()) for v in bbox.split(",")]
93
+ if len(parts) != 4:
94
+ typer.echo("Error: --bbox requires west,south,east,north", err=True)
95
+ raise typer.Exit(code=1)
96
+ bbox_tuple = (parts[0], parts[1], parts[2], parts[3])
97
+
98
+ async def _run_bench() -> BenchResult:
99
+ from pathlib import Path
100
+
101
+ try:
102
+ import geopandas as gpd
103
+ import pyarrow.parquet as pq
104
+ except ImportError as exc:
105
+ typer.echo(f"Error: geopandas and pyarrow required for bench: {exc}", err=True)
106
+ raise typer.Exit(code=1) from exc
107
+
108
+ from earthforge.vector.query import query_features
109
+
110
+ file_size = Path(source).stat().st_size
111
+
112
+ pushdown_times: list[float] = []
113
+ fullscan_times: list[float] = []
114
+ pushdown_rows = 0
115
+ fullscan_rows = 0
116
+
117
+ for _ in range(runs):
118
+ # Method 1: EarthForge predicate pushdown
119
+ t0 = time.perf_counter()
120
+ result = await query_features(source, bbox=list(bbox_tuple))
121
+ pushdown_times.append(time.perf_counter() - t0)
122
+ pushdown_rows = result.feature_count
123
+
124
+ # Method 2: Full scan — load all, filter in Python
125
+ t0 = time.perf_counter()
126
+ gdf = gpd.read_parquet(source)
127
+ west, south, east, north = bbox_tuple
128
+ _ = gdf.cx[west:east, south:north] # type: ignore[misc]
129
+ fullscan_times.append(time.perf_counter() - t0)
130
+ fullscan_rows = len(_)
131
+
132
+ # Estimate row groups read by pushdown (pyarrow metadata)
133
+ pf = pq.ParquetFile(source)
134
+ total_row_groups = pf.metadata.num_row_groups
135
+
136
+ speedup = (
137
+ round(sum(fullscan_times) / sum(pushdown_times), 2) if sum(pushdown_times) > 0 else 0.0
138
+ )
139
+
140
+ return BenchResult(
141
+ benchmark="vector-query",
142
+ description=(
143
+ f"GeoParquet bbox query: pushdown vs. full scan "
144
+ f"({file_size:,} bytes, {total_row_groups} row groups)"
145
+ ),
146
+ runs=runs,
147
+ results=[
148
+ {
149
+ "method": "earthforge predicate pushdown",
150
+ "rows_returned": pushdown_rows,
151
+ **_stats(pushdown_times),
152
+ },
153
+ {
154
+ "method": "geopandas full scan",
155
+ "rows_returned": fullscan_rows,
156
+ **_stats(fullscan_times),
157
+ },
158
+ ],
159
+ summary={
160
+ "file_size_bytes": file_size,
161
+ "total_row_groups": total_row_groups,
162
+ "pushdown_speedup_x": speedup,
163
+ "bbox": list(bbox_tuple),
164
+ },
165
+ )
166
+
167
+ result = asyncio.run(_run_bench())
168
+ render_to_console(result, state.output, no_color=state.no_color)
169
+
170
+
171
+ def raster_info(
172
+ ctx: typer.Context,
173
+ source: str = typer.Argument(
174
+ help="URL or path to a COG to benchmark header read time.",
175
+ ),
176
+ runs: int = typer.Option(
177
+ 3,
178
+ "--runs",
179
+ "-n",
180
+ help="Number of timed repetitions.",
181
+ ),
182
+ ) -> None:
183
+ """Benchmark COG header read time via HTTP range requests."""
184
+ from earthforge.cli.main import get_state
185
+
186
+ state = get_state(ctx)
187
+
188
+ async def _run_bench() -> BenchResult:
189
+ from earthforge.raster.info import inspect_raster
190
+
191
+ times: list[float] = []
192
+ for _ in range(runs):
193
+ t0 = time.perf_counter()
194
+ info = await inspect_raster(source)
195
+ times.append(time.perf_counter() - t0)
196
+
197
+ return BenchResult(
198
+ benchmark="raster-info",
199
+ description=f"COG header read via range request: {source}",
200
+ runs=runs,
201
+ results=[{"run": i + 1, "elapsed_s": round(t, 4)} for i, t in enumerate(times)],
202
+ summary={
203
+ **_stats(times),
204
+ "dimensions": f"{info.width}x{info.height}",
205
+ "overview_count": info.overview_count,
206
+ "compression": info.compression,
207
+ },
208
+ )
209
+
210
+ result = asyncio.run(_run_bench())
211
+ render_to_console(result, state.output, no_color=state.no_color)
212
+
213
+
214
+ app.command(name="vector-query", help="Benchmark GeoParquet bbox query performance.")(vector_query)
215
+ app.command(name="raster-info", help="Benchmark COG header read time.")(raster_info)
@@ -0,0 +1,92 @@
1
+ """EarthForge ``completions`` command — shell completion script generation.
2
+
3
+ Generates shell completion scripts for bash, zsh, and fish. The output is
4
+ printed to stdout so it can be redirected to the appropriate location:
5
+
6
+ earthforge completions bash >> ~/.bashrc
7
+ earthforge completions zsh >> ~/.zshrc
8
+ earthforge completions fish > ~/.config/fish/completions/earthforge.fish
9
+
10
+ Typer also installs completions automatically via the hidden flags
11
+ ``--install-completion`` and ``--show-completion`` that it adds to the root
12
+ app. This module provides an explicit, documentable command as an alternative.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import subprocess
18
+ import sys
19
+
20
+ import typer
21
+
22
+ _SUPPORTED_SHELLS = ("bash", "zsh", "fish")
23
+
24
+ _INSTRUCTIONS: dict[str, str] = {
25
+ "bash": "# Add to ~/.bashrc:\n# source <(earthforge completions bash)",
26
+ "zsh": "# Add to ~/.zshrc:\n# source <(earthforge completions zsh)",
27
+ "fish": "# Save to ~/.config/fish/completions/earthforge.fish",
28
+ }
29
+
30
+
31
+ def completions(
32
+ shell: str = typer.Argument(
33
+ help=f"Shell to generate completions for: {', '.join(_SUPPORTED_SHELLS)}.",
34
+ ),
35
+ ) -> None:
36
+ """Print a shell completion script to stdout.
37
+
38
+ Redirect the output to your shell's configuration to enable tab completion:
39
+
40
+ earthforge completions bash >> ~/.bashrc
41
+
42
+ earthforge completions zsh >> ~/.zshrc
43
+
44
+ earthforge completions fish > ~/.config/fish/completions/earthforge.fish
45
+ """
46
+ if shell not in _SUPPORTED_SHELLS:
47
+ typer.echo(
48
+ f"Error: unsupported shell '{shell}'. Supported: {', '.join(_SUPPORTED_SHELLS)}",
49
+ err=True,
50
+ )
51
+ raise typer.Exit(code=1)
52
+
53
+ # Typer/Click exposes shell completions via the _COMPLETE env var mechanism.
54
+ # We invoke the CLI subprocess with the appropriate env variable to capture
55
+ # the completion script rather than re-implementing the generation logic.
56
+ import os
57
+
58
+ env_key = f"_{_app_name().upper().replace('-', '_')}_COMPLETE"
59
+ env_val = f"{shell}_source"
60
+
61
+ try:
62
+ env = os.environ.copy()
63
+ env[env_key] = env_val
64
+ result = subprocess.run( # noqa: S603
65
+ [sys.executable, "-m", "earthforge"],
66
+ env=env,
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=10,
70
+ )
71
+ script = result.stdout or result.stderr
72
+ except (subprocess.TimeoutExpired, OSError):
73
+ # Fallback: emit the instructions comment and let the user use
74
+ # --show-completion instead
75
+ script = ""
76
+
77
+ if script.strip():
78
+ typer.echo(f"# Generated by earthforge completions {shell}")
79
+ typer.echo(f"{_INSTRUCTIONS[shell]}\n")
80
+ typer.echo(script, nl=False)
81
+ else:
82
+ # Typer's built-in mechanism is more reliable — guide the user
83
+ typer.echo(f"{_INSTRUCTIONS[shell]}\n")
84
+ typer.echo(
85
+ f"# Run the following to install completions directly:\n"
86
+ f"earthforge --install-completion {shell}"
87
+ )
88
+
89
+
90
+ def _app_name() -> str:
91
+ """Return the CLI entry-point name for the completion env variable."""
92
+ return "earthforge"
@@ -0,0 +1,69 @@
1
+ """EarthForge ``config`` command group — manage configuration profiles.
2
+
3
+ Provides commands for initializing, viewing, and managing EarthForge
4
+ configuration profiles. Profiles control STAC API endpoints, cloud storage
5
+ backends, and authentication settings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+
12
+ from earthforge.core.output import render_to_console
13
+
14
+ app = typer.Typer(
15
+ name="config",
16
+ help="Manage EarthForge configuration profiles.",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+
21
+ def init(
22
+ ctx: typer.Context,
23
+ overwrite: bool = typer.Option(
24
+ False,
25
+ "--overwrite",
26
+ help="Overwrite existing config file.",
27
+ ),
28
+ ) -> None:
29
+ """Create a default configuration file."""
30
+ from earthforge.cli.main import run_command
31
+ from earthforge.core.config import init_config
32
+
33
+ result = run_command(ctx, init_config(overwrite=overwrite))
34
+ if result is not None:
35
+ typer.echo(f"Config file created: {result}")
36
+
37
+
38
+ def show(
39
+ ctx: typer.Context,
40
+ ) -> None:
41
+ """Show the active profile configuration."""
42
+ from pydantic import BaseModel, Field
43
+
44
+ from earthforge.cli.main import get_state, run_command
45
+ from earthforge.core.config import EarthForgeProfile, load_profile
46
+
47
+ class ProfileView(BaseModel):
48
+ """Rendered view of a profile for CLI output."""
49
+
50
+ name: str = Field(title="Profile")
51
+ stac_api: str | None = Field(default=None, title="STAC API")
52
+ storage_backend: str = Field(title="Storage")
53
+ storage_options: dict[str, str] = Field(default_factory=dict, title="Options")
54
+
55
+ state = get_state(ctx)
56
+ result = run_command(ctx, load_profile(state.profile))
57
+
58
+ if isinstance(result, EarthForgeProfile):
59
+ view = ProfileView(
60
+ name=result.name,
61
+ stac_api=result.stac_api,
62
+ storage_backend=result.storage_backend,
63
+ storage_options=result.storage_options,
64
+ )
65
+ render_to_console(view, state.output, no_color=state.no_color)
66
+
67
+
68
+ app.command(name="init", help="Create a default configuration file.")(init)
69
+ app.command(name="show", help="Show the active profile configuration.")(show)
@@ -0,0 +1,104 @@
1
+ """EarthForge ``cube`` command group — Zarr and NetCDF datacube operations.
2
+
3
+ Provides commands for inspecting and slicing multidimensional geospatial
4
+ datacubes stored as Zarr or NetCDF. Inspection reads only consolidated
5
+ metadata; slicing fetches only the chunks that intersect the requested
6
+ spatiotemporal extent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+ from pydantic import BaseModel
13
+
14
+ from earthforge.core.output import render_to_console
15
+
16
+ app = typer.Typer(
17
+ name="cube",
18
+ help="Inspect and slice Zarr and NetCDF datacubes.",
19
+ no_args_is_help=True,
20
+ )
21
+
22
+
23
+ def info(
24
+ ctx: typer.Context,
25
+ source: str = typer.Argument(
26
+ help="Zarr store path/URL or NetCDF file path.",
27
+ ),
28
+ ) -> None:
29
+ """Inspect datacube metadata (dimensions, variables, CRS, extent)."""
30
+ from earthforge.cli.main import get_state, run_command
31
+ from earthforge.cube.info import inspect_cube
32
+
33
+ state = get_state(ctx)
34
+ result = run_command(ctx, inspect_cube(source))
35
+ if isinstance(result, BaseModel):
36
+ render_to_console(result, state.output, no_color=state.no_color)
37
+
38
+
39
+ def slice_cmd(
40
+ ctx: typer.Context,
41
+ source: str = typer.Argument(
42
+ help="Zarr store path/URL or NetCDF file path.",
43
+ ),
44
+ output: str = typer.Option(
45
+ ...,
46
+ "--output",
47
+ "-o",
48
+ help="Output path. Use .zarr suffix for Zarr output, .nc for NetCDF.",
49
+ ),
50
+ variables: str | None = typer.Option(
51
+ None,
52
+ "--var",
53
+ help="Comma-separated variable names to include. Default: all.",
54
+ ),
55
+ bbox: str | None = typer.Option(
56
+ None,
57
+ "--bbox",
58
+ help="Spatial filter: west,south,east,north (dataset CRS units).",
59
+ ),
60
+ time_range: str | None = typer.Option(
61
+ None,
62
+ "--time",
63
+ help="Time range: YYYY-MM-DD/YYYY-MM-DD or YYYY-MM/YYYY-MM.",
64
+ ),
65
+ ) -> None:
66
+ """Slice a datacube by variables, bounding box, and/or time range."""
67
+ from earthforge.cli.main import get_state, run_command
68
+ from earthforge.cube.slice import slice_cube
69
+
70
+ state = get_state(ctx)
71
+
72
+ # Parse bbox
73
+ bbox_tuple: tuple[float, float, float, float] | None = None
74
+ if bbox:
75
+ parts = [float(v.strip()) for v in bbox.split(",")]
76
+ if len(parts) != 4:
77
+ typer.echo(
78
+ "Error: --bbox requires exactly 4 values: west,south,east,north",
79
+ err=True,
80
+ )
81
+ raise typer.Exit(code=1)
82
+ bbox_tuple = (parts[0], parts[1], parts[2], parts[3])
83
+
84
+ # Parse variables
85
+ var_list: list[str] | None = None
86
+ if variables:
87
+ var_list = [v.strip() for v in variables.split(",")]
88
+
89
+ result = run_command(
90
+ ctx,
91
+ slice_cube(
92
+ source,
93
+ variables=var_list,
94
+ bbox=bbox_tuple,
95
+ time_range=time_range,
96
+ output=output,
97
+ ),
98
+ )
99
+ if isinstance(result, BaseModel):
100
+ render_to_console(result, state.output, no_color=state.no_color)
101
+
102
+
103
+ app.command(name="info", help="Inspect datacube metadata.")(info)
104
+ app.command(name="slice", help="Slice a datacube by variables, bbox, and time.")(slice_cmd)
@@ -0,0 +1,98 @@
1
+ """EarthForge ``explore`` command — interactive STAC TUI.
2
+
3
+ Launches the Textual-based interactive terminal user interface for browsing
4
+ STAC catalogs. The TUI provides a three-panel layout:
5
+
6
+ - **Collections** — all collections at the STAC API endpoint
7
+ - **Items** — up to 50 items in the selected collection
8
+ - **Detail** — full metadata, assets, and links for the selected item
9
+
10
+ Keyboard shortcuts inside the TUI
11
+ ----------------------------------
12
+ ``q``
13
+ Quit the explorer.
14
+ ``r``
15
+ Refresh the collection list.
16
+ ``/``
17
+ Show filter hint (bbox filtering is applied at launch).
18
+ ``Tab`` / ``Shift+Tab``
19
+ Cycle focus between the three panels.
20
+ ``Enter``
21
+ Select the focused collection or item.
22
+ ``↑`` / ``↓``
23
+ Navigate within a panel.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import typer
29
+
30
+ app = typer.Typer(name="explore", help="Interactive STAC catalog explorer (TUI).")
31
+
32
+ _DEFAULT_API = "https://earth-search.aws.element84.com/v1"
33
+
34
+
35
+ @app.callback(invoke_without_command=True)
36
+ def explore(
37
+ ctx: typer.Context,
38
+ api: str = typer.Option(
39
+ _DEFAULT_API,
40
+ "--api",
41
+ help="STAC API root URL to browse.",
42
+ show_default=True,
43
+ ),
44
+ collection: str | None = typer.Option(
45
+ None,
46
+ "--collection",
47
+ "-c",
48
+ help="Pre-select and open this collection on startup.",
49
+ ),
50
+ bbox: str | None = typer.Option(
51
+ None,
52
+ "--bbox",
53
+ help="Spatial filter: west,south,east,north (applied to item searches).",
54
+ ),
55
+ ) -> None:
56
+ """Launch the interactive STAC catalog explorer.
57
+
58
+ Opens a full-screen terminal UI connected to the given STAC API. Browse
59
+ collections, inspect items, and view asset metadata — all without leaving
60
+ the terminal.
61
+
62
+ Parameters:
63
+ ctx: Typer command context (for global state).
64
+ api: STAC API endpoint URL.
65
+ collection: Optional collection to open on startup.
66
+ bbox: Optional bounding-box filter string.
67
+
68
+ Raises:
69
+ typer.Exit: With code 1 on invalid ``--bbox`` input.
70
+ """
71
+ try:
72
+ from earthforge.cli.tui.app import ExploreApp
73
+ except ImportError as exc:
74
+ typer.echo(
75
+ f"Error: textual is required for explore: {exc}\n"
76
+ "Install with: pip install earthforge[cli]",
77
+ err=True,
78
+ )
79
+ raise typer.Exit(code=1) from exc
80
+
81
+ bbox_tuple: tuple[float, float, float, float] | None = None
82
+ if bbox:
83
+ parts = [v.strip() for v in bbox.split(",")]
84
+ if len(parts) != 4:
85
+ typer.echo("Error: --bbox requires west,south,east,north", err=True)
86
+ raise typer.Exit(code=1)
87
+ try:
88
+ w, s, e, n = (float(p) for p in parts)
89
+ except ValueError:
90
+ typer.echo("Error: --bbox values must be numeric", err=True)
91
+ raise typer.Exit(code=1) # noqa: B904
92
+ bbox_tuple = (w, s, e, n)
93
+
94
+ ExploreApp(
95
+ api_url=api,
96
+ initial_collection=collection,
97
+ bbox=bbox_tuple,
98
+ ).run()