earthforge-core 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_core-0.1.0/.gitignore +31 -0
- earthforge_core-0.1.0/PKG-INFO +18 -0
- earthforge_core-0.1.0/README.md +5 -0
- earthforge_core-0.1.0/pyproject.toml +21 -0
- earthforge_core-0.1.0/src/earthforge/core/__init__.py +8 -0
- earthforge_core-0.1.0/src/earthforge/core/config.py +239 -0
- earthforge_core-0.1.0/src/earthforge/core/errors.py +85 -0
- earthforge_core-0.1.0/src/earthforge/core/formats.py +440 -0
- earthforge_core-0.1.0/src/earthforge/core/http.py +205 -0
- earthforge_core-0.1.0/src/earthforge/core/output.py +206 -0
- earthforge_core-0.1.0/src/earthforge/core/storage.py +272 -0
- earthforge_core-0.1.0/tests/test_config.py +185 -0
- earthforge_core-0.1.0/tests/test_errors.py +90 -0
- earthforge_core-0.1.0/tests/test_formats.py +260 -0
- earthforge_core-0.1.0/tests/test_http.py +160 -0
- earthforge_core-0.1.0/tests/test_output.py +203 -0
- earthforge_core-0.1.0/tests/test_storage.py +147 -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,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: earthforge-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: EarthForge core: shared types, config, storage, output, format detection
|
|
5
|
+
License-Expression: GPL-3.0-or-later
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: obstore>=0.3
|
|
9
|
+
Requires-Dist: orjson>=3.9
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# earthforge-core
|
|
15
|
+
|
|
16
|
+
Shared types, configuration, storage abstraction, output formatting, and format detection for EarthForge.
|
|
17
|
+
|
|
18
|
+
See the [main README](../../README.md) for project documentation.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "earthforge-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "EarthForge core: shared types, config, storage, output, format detection"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "GPL-3.0-or-later"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"obstore>=0.3",
|
|
15
|
+
"pydantic>=2.0",
|
|
16
|
+
"rich>=13.0",
|
|
17
|
+
"orjson>=3.9",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/earthforge"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""EarthForge core — shared types, configuration, storage, output, and format detection.
|
|
2
|
+
|
|
3
|
+
This package provides the foundational layer that all EarthForge domain packages
|
|
4
|
+
depend on. It wraps third-party libraries (httpx, obstore, rich) behind stable
|
|
5
|
+
interfaces so domain code never imports them directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""EarthForge configuration management.
|
|
2
|
+
|
|
3
|
+
Provides profile-based configuration backed by a TOML file at
|
|
4
|
+
``~/.earthforge/config.toml``. Each profile bundles a STAC API endpoint,
|
|
5
|
+
a storage backend selection, and backend-specific options (credentials,
|
|
6
|
+
regions, endpoints). The ``default`` profile is used when no ``--profile``
|
|
7
|
+
flag is given.
|
|
8
|
+
|
|
9
|
+
Configuration file format::
|
|
10
|
+
|
|
11
|
+
[profiles.default]
|
|
12
|
+
stac_api = "https://earth-search.aws.element84.com/v1"
|
|
13
|
+
storage = "s3"
|
|
14
|
+
|
|
15
|
+
[profiles.default.storage_options]
|
|
16
|
+
region = "us-west-2"
|
|
17
|
+
|
|
18
|
+
Functions:
|
|
19
|
+
load_profile: Async loader that reads config and returns a typed profile.
|
|
20
|
+
load_profile_sync: Convenience sync wrapper.
|
|
21
|
+
init_config: Creates a starter config file with a default profile.
|
|
22
|
+
config_dir: Returns the resolved config directory path.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import logging
|
|
29
|
+
import tomllib
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Self
|
|
33
|
+
|
|
34
|
+
from earthforge.core.errors import ConfigError
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
#: Supported storage backend identifiers.
|
|
39
|
+
VALID_BACKENDS = frozenset({"s3", "gcs", "azure", "local"})
|
|
40
|
+
|
|
41
|
+
#: Default STAC API endpoint used when none is configured.
|
|
42
|
+
DEFAULT_STAC_API = "https://earth-search.aws.element84.com/v1"
|
|
43
|
+
|
|
44
|
+
_DEFAULT_CONFIG = """\
|
|
45
|
+
# EarthForge configuration
|
|
46
|
+
# Documentation: https://earthforge-geo.github.io/earthforge/config
|
|
47
|
+
|
|
48
|
+
[profiles.default]
|
|
49
|
+
stac_api = "https://earth-search.aws.element84.com/v1"
|
|
50
|
+
storage = "local"
|
|
51
|
+
|
|
52
|
+
[profiles.default.storage_options]
|
|
53
|
+
root = "."
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class EarthForgeProfile:
|
|
59
|
+
"""A named configuration profile.
|
|
60
|
+
|
|
61
|
+
Parameters:
|
|
62
|
+
name: Profile identifier (e.g. ``"default"``, ``"planetary"``).
|
|
63
|
+
stac_api: Base URL for the STAC API, or ``None`` if not configured.
|
|
64
|
+
storage_backend: One of ``"s3"``, ``"gcs"``, ``"azure"``, ``"local"``.
|
|
65
|
+
storage_options: Backend-specific key/value pairs (region, credentials, etc.).
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ConfigError: If ``storage_backend`` is not in ``VALID_BACKENDS``.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
stac_api: str | None = None
|
|
73
|
+
storage_backend: str = "local"
|
|
74
|
+
storage_options: dict[str, str] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
def __post_init__(self) -> None:
|
|
77
|
+
if self.storage_backend not in VALID_BACKENDS:
|
|
78
|
+
msg = (
|
|
79
|
+
f"Unknown storage backend {self.storage_backend!r} in profile "
|
|
80
|
+
f"{self.name!r}. Valid options: {', '.join(sorted(VALID_BACKENDS))}"
|
|
81
|
+
)
|
|
82
|
+
raise ConfigError(msg)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, name: str, data: dict[str, object]) -> Self:
|
|
86
|
+
"""Construct a profile from a parsed TOML dictionary.
|
|
87
|
+
|
|
88
|
+
Parameters:
|
|
89
|
+
name: The profile name key.
|
|
90
|
+
data: The TOML table for this profile.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A validated ``EarthForgeProfile``.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ConfigError: If required fields are missing or have wrong types.
|
|
97
|
+
"""
|
|
98
|
+
stac_api = data.get("stac_api")
|
|
99
|
+
if stac_api is not None and not isinstance(stac_api, str):
|
|
100
|
+
raise ConfigError(f"Profile {name!r}: stac_api must be a string")
|
|
101
|
+
|
|
102
|
+
storage = data.get("storage", "local")
|
|
103
|
+
if not isinstance(storage, str):
|
|
104
|
+
raise ConfigError(f"Profile {name!r}: storage must be a string")
|
|
105
|
+
|
|
106
|
+
raw_options = data.get("storage_options", {})
|
|
107
|
+
if not isinstance(raw_options, dict):
|
|
108
|
+
raise ConfigError(f"Profile {name!r}: storage_options must be a table")
|
|
109
|
+
|
|
110
|
+
storage_options: dict[str, str] = {}
|
|
111
|
+
for k, v in raw_options.items():
|
|
112
|
+
if not isinstance(v, str):
|
|
113
|
+
raise ConfigError(
|
|
114
|
+
f"Profile {name!r}: storage_options.{k} must be a string, "
|
|
115
|
+
f"got {type(v).__name__}"
|
|
116
|
+
)
|
|
117
|
+
storage_options[k] = v
|
|
118
|
+
|
|
119
|
+
return cls(
|
|
120
|
+
name=name,
|
|
121
|
+
stac_api=stac_api if isinstance(stac_api, str) else None,
|
|
122
|
+
storage_backend=storage,
|
|
123
|
+
storage_options=storage_options,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def config_dir() -> Path:
|
|
128
|
+
"""Return the EarthForge configuration directory.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
``Path("~/.earthforge")`` expanded to an absolute path.
|
|
132
|
+
"""
|
|
133
|
+
return Path.home() / ".earthforge"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _config_file() -> Path:
|
|
137
|
+
"""Return the path to the main config file."""
|
|
138
|
+
return config_dir() / "config.toml"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def load_profile(name: str = "default") -> EarthForgeProfile:
|
|
142
|
+
"""Load a named profile from the configuration file.
|
|
143
|
+
|
|
144
|
+
If no config file exists, returns a built-in default profile (for the
|
|
145
|
+
``"default"`` name) or raises ``ConfigError`` for any other name.
|
|
146
|
+
|
|
147
|
+
Parameters:
|
|
148
|
+
name: Profile name to load.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The resolved ``EarthForgeProfile``.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ConfigError: If the config file is malformed, the profile doesn't exist,
|
|
155
|
+
or field validation fails.
|
|
156
|
+
"""
|
|
157
|
+
path = _config_file()
|
|
158
|
+
|
|
159
|
+
if not path.exists():
|
|
160
|
+
logger.debug("No config file at %s, using built-in defaults", path)
|
|
161
|
+
if name == "default":
|
|
162
|
+
return EarthForgeProfile(
|
|
163
|
+
name="default",
|
|
164
|
+
stac_api=DEFAULT_STAC_API,
|
|
165
|
+
storage_backend="local",
|
|
166
|
+
storage_options={"root": "."},
|
|
167
|
+
)
|
|
168
|
+
raise ConfigError(
|
|
169
|
+
f"Profile {name!r} not found: no config file at {path}. "
|
|
170
|
+
f"Run 'earthforge config init' to create one."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
raw = path.read_bytes()
|
|
175
|
+
config = tomllib.loads(raw.decode("utf-8"))
|
|
176
|
+
except tomllib.TOMLDecodeError as exc:
|
|
177
|
+
raise ConfigError(f"Invalid TOML in {path}: {exc}") from exc
|
|
178
|
+
except OSError as exc:
|
|
179
|
+
raise ConfigError(f"Cannot read config file {path}: {exc}") from exc
|
|
180
|
+
|
|
181
|
+
profiles = config.get("profiles")
|
|
182
|
+
if not isinstance(profiles, dict):
|
|
183
|
+
raise ConfigError(f"Config file {path} is missing [profiles] section")
|
|
184
|
+
|
|
185
|
+
if name not in profiles:
|
|
186
|
+
available = ", ".join(sorted(profiles.keys())) or "(none)"
|
|
187
|
+
raise ConfigError(f"Profile {name!r} not found in {path}. Available: {available}")
|
|
188
|
+
|
|
189
|
+
profile_data = profiles[name]
|
|
190
|
+
if not isinstance(profile_data, dict):
|
|
191
|
+
raise ConfigError(f"Profile {name!r} must be a TOML table")
|
|
192
|
+
|
|
193
|
+
return EarthForgeProfile.from_dict(name, profile_data)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_profile_sync(name: str = "default") -> EarthForgeProfile:
|
|
197
|
+
"""Synchronous convenience wrapper for :func:`load_profile`.
|
|
198
|
+
|
|
199
|
+
Parameters:
|
|
200
|
+
name: Profile name to load.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
The resolved ``EarthForgeProfile``.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ConfigError: Same conditions as :func:`load_profile`.
|
|
207
|
+
"""
|
|
208
|
+
return asyncio.run(load_profile(name))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def init_config(*, overwrite: bool = False) -> Path:
|
|
212
|
+
"""Create the default configuration file.
|
|
213
|
+
|
|
214
|
+
Parameters:
|
|
215
|
+
overwrite: If ``True``, replace an existing config file. If ``False``
|
|
216
|
+
and the file already exists, raise ``ConfigError``.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The path to the created config file.
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
ConfigError: If the file exists and ``overwrite`` is ``False``,
|
|
223
|
+
or if the directory cannot be created.
|
|
224
|
+
"""
|
|
225
|
+
path = _config_file()
|
|
226
|
+
|
|
227
|
+
if path.exists() and not overwrite:
|
|
228
|
+
raise ConfigError(
|
|
229
|
+
f"Config file already exists at {path}. Use overwrite=True to replace it."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
path.write_text(_DEFAULT_CONFIG, encoding="utf-8")
|
|
235
|
+
except OSError as exc:
|
|
236
|
+
raise ConfigError(f"Cannot create config file at {path}: {exc}") from exc
|
|
237
|
+
|
|
238
|
+
logger.info("Created config file at %s", path)
|
|
239
|
+
return path
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""EarthForge error hierarchy.
|
|
2
|
+
|
|
3
|
+
All exceptions raised by EarthForge inherit from ``EarthForgeError``. Each domain
|
|
4
|
+
package defines its own subclasses (e.g. ``StacSearchError``, ``CogValidationError``)
|
|
5
|
+
so callers can catch at whatever granularity they need. The ``exit_code`` attribute
|
|
6
|
+
maps directly to CLI exit codes, letting the CLI layer translate library exceptions
|
|
7
|
+
into meaningful shell return values without parsing message strings.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EarthForgeError(Exception):
|
|
14
|
+
"""Base exception for all EarthForge errors.
|
|
15
|
+
|
|
16
|
+
Parameters:
|
|
17
|
+
message: Human-readable description of the error.
|
|
18
|
+
exit_code: CLI exit code to use when this error propagates to the shell.
|
|
19
|
+
Defaults to ``1`` (general error).
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
exit_code: The numeric exit code for CLI propagation.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
exit_code: int = 1
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, *, exit_code: int = 1) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.exit_code = exit_code
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(EarthForgeError):
|
|
33
|
+
"""Raised when configuration loading, parsing, or validation fails.
|
|
34
|
+
|
|
35
|
+
Examples: missing config file, invalid TOML, unknown profile name,
|
|
36
|
+
missing required field in a profile.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, *, exit_code: int = 2) -> None:
|
|
40
|
+
super().__init__(message, exit_code=exit_code)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StorageError(EarthForgeError):
|
|
44
|
+
"""Raised when a cloud storage operation fails.
|
|
45
|
+
|
|
46
|
+
Examples: permission denied on S3, object not found, network timeout,
|
|
47
|
+
invalid storage backend name.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, *, exit_code: int = 3) -> None:
|
|
51
|
+
super().__init__(message, exit_code=exit_code)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HttpError(EarthForgeError):
|
|
55
|
+
"""Raised when an HTTP request fails after retries.
|
|
56
|
+
|
|
57
|
+
Parameters:
|
|
58
|
+
message: Human-readable description.
|
|
59
|
+
status_code: The HTTP status code that triggered the error, if available.
|
|
60
|
+
exit_code: CLI exit code (defaults to ``4``).
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
status_code: The HTTP status code, or ``None`` for connection-level failures.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
message: str,
|
|
69
|
+
*,
|
|
70
|
+
status_code: int | None = None,
|
|
71
|
+
exit_code: int = 4,
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(message, exit_code=exit_code)
|
|
74
|
+
self.status_code = status_code
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FormatDetectionError(EarthForgeError):
|
|
78
|
+
"""Raised when format detection cannot determine the file type.
|
|
79
|
+
|
|
80
|
+
This typically means the file's magic bytes don't match any known format,
|
|
81
|
+
the extension is unrecognized, and content inspection was inconclusive.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, message: str, *, exit_code: int = 5) -> None:
|
|
85
|
+
super().__init__(message, exit_code=exit_code)
|