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.
- earthforge_cli-0.1.0/.gitignore +31 -0
- earthforge_cli-0.1.0/PKG-INFO +16 -0
- earthforge_cli-0.1.0/README.md +5 -0
- earthforge_cli-0.1.0/pyproject.toml +23 -0
- earthforge_cli-0.1.0/src/earthforge/cli/__init__.py +6 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/__init__.py +5 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/bench_cmd.py +215 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/completions_cmd.py +92 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/config_cmd.py +69 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/cube_cmd.py +104 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/explore_cmd.py +98 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/info.py +136 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/pipeline_cmd.py +107 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/raster_cmd.py +121 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/stac_cmd.py +163 -0
- earthforge_cli-0.1.0/src/earthforge/cli/commands/vector_cmd.py +126 -0
- earthforge_cli-0.1.0/src/earthforge/cli/main.py +181 -0
- earthforge_cli-0.1.0/src/earthforge/cli/tui/__init__.py +7 -0
- earthforge_cli-0.1.0/src/earthforge/cli/tui/app.py +368 -0
- earthforge_cli-0.1.0/tests/test_explore_cmd.py +194 -0
- earthforge_cli-0.1.0/tests/test_main.py +149 -0
|
@@ -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,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,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()
|