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.
@@ -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,5 @@
1
+ # earthforge-core
2
+
3
+ Shared types, configuration, storage abstraction, output formatting, and format detection for EarthForge.
4
+
5
+ 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)