datacosmos 0.0.12__tar.gz → 0.0.14__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.
- {datacosmos-0.0.12 → datacosmos-0.0.14}/PKG-INFO +2 -1
- datacosmos-0.0.14/datacosmos/auth/__init__.py +1 -0
- datacosmos-0.0.14/datacosmos/auth/local_token_fetcher.py +156 -0
- datacosmos-0.0.14/datacosmos/auth/token.py +82 -0
- datacosmos-0.0.14/datacosmos/config/auth/__init__.py +1 -0
- datacosmos-0.0.14/datacosmos/config/auth/factory.py +157 -0
- datacosmos-0.0.14/datacosmos/config/config.py +101 -0
- datacosmos-0.0.14/datacosmos/config/constants.py +26 -0
- datacosmos-0.0.14/datacosmos/config/loaders/yaml_source.py +62 -0
- datacosmos-0.0.14/datacosmos/config/models/local_user_account_authentication_config.py +28 -0
- datacosmos-0.0.14/datacosmos/config/models/m2m_authentication_config.py +25 -0
- datacosmos-0.0.14/datacosmos/datacosmos_client.py +276 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos.egg-info/PKG-INFO +2 -1
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos.egg-info/SOURCES.txt +7 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos.egg-info/requires.txt +1 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/pyproject.toml +2 -1
- datacosmos-0.0.12/datacosmos/config/config.py +0 -222
- datacosmos-0.0.12/datacosmos/config/models/local_user_account_authentication_config.py +0 -26
- datacosmos-0.0.12/datacosmos/config/models/m2m_authentication_config.py +0 -27
- datacosmos-0.0.12/datacosmos/datacosmos_client.py +0 -152
- {datacosmos-0.0.12 → datacosmos-0.0.14}/LICENSE.md +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/README.md +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/config/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/config/models/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/config/models/authentication_config.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/config/models/no_authentication_config.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/config/models/url.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/exceptions/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/exceptions/datacosmos_exception.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/collection/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/collection/collection_client.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/collection/models/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/collection/models/collection_update.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/constants/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/constants/satellite_name_mapping.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/enums/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/enums/processing_level.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/enums/product_type.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/enums/season.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/item_client.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/models/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/models/asset.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/models/catalog_search_parameters.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/models/datacosmos_item.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/models/eo_band.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/models/item_update.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/item/models/raster_band.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/stac_client.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/storage/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/storage/dataclasses/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/storage/dataclasses/upload_path.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/storage/storage_base.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/storage/storage_client.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/stac/storage/uploader.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/utils/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/utils/http_response/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/utils/http_response/check_api_response.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/__init__.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/datacosmos_error.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/datacosmos_response.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos/utils/url.py +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos.egg-info/dependency_links.txt +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/datacosmos.egg-info/top_level.txt +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/setup.cfg +0 -0
- {datacosmos-0.0.12 → datacosmos-0.0.14}/tests/test_pass.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datacosmos
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.14
|
|
4
4
|
Summary: A library for interacting with DataCosmos from Python code
|
|
5
5
|
Author-email: Open Cosmos <support@open-cosmos.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -20,6 +20,7 @@ Requires-Dist: black==22.3.0; extra == "dev"
|
|
|
20
20
|
Requires-Dist: ruff==0.9.5; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest==7.2.0; extra == "dev"
|
|
22
22
|
Requires-Dist: bandit[toml]==1.7.4; extra == "dev"
|
|
23
|
+
Requires-Dist: pbr>=6.0.0; extra == "dev"
|
|
23
24
|
Requires-Dist: isort==5.11.4; extra == "dev"
|
|
24
25
|
Requires-Dist: pydocstyle==6.1.1; extra == "dev"
|
|
25
26
|
Dynamic: license-file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Handles authentication related things."""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Opens a browser for the user to log in (Authorization Code), caches token to a file, and refreshes when expired."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.server
|
|
6
|
+
import json
|
|
7
|
+
import socketserver
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import webbrowser
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from datacosmos.auth.token import Token
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class LocalTokenFetcher:
|
|
23
|
+
"""Opens a browser for the user to log in (Authorization Code), caches token to a file, and refreshes when expired."""
|
|
24
|
+
|
|
25
|
+
client_id: str
|
|
26
|
+
authorization_endpoint: str
|
|
27
|
+
token_endpoint: str
|
|
28
|
+
redirect_port: int
|
|
29
|
+
audience: str
|
|
30
|
+
scopes: str
|
|
31
|
+
token_file: Path
|
|
32
|
+
|
|
33
|
+
def get_token(self) -> Token:
|
|
34
|
+
"""Return a valid token from cache, or refresh / interact as needed."""
|
|
35
|
+
tok = self.__load()
|
|
36
|
+
if not tok:
|
|
37
|
+
return self.__interactive_login()
|
|
38
|
+
|
|
39
|
+
if tok.is_expired():
|
|
40
|
+
# Try to refresh; if that fails for any reason, fall back to interactive login.
|
|
41
|
+
try:
|
|
42
|
+
return self.__refresh(tok)
|
|
43
|
+
except (requests.HTTPError, RuntimeError):
|
|
44
|
+
return self.__interactive_login()
|
|
45
|
+
|
|
46
|
+
return tok
|
|
47
|
+
|
|
48
|
+
def __save(self, token: Token) -> None:
|
|
49
|
+
self.token_file.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
with open(self.token_file, "w") as f:
|
|
51
|
+
json.dump(
|
|
52
|
+
{
|
|
53
|
+
"access_token": token.access_token,
|
|
54
|
+
"refresh_token": token.refresh_token,
|
|
55
|
+
"expires_at": token.expires_at,
|
|
56
|
+
},
|
|
57
|
+
f,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def __load(self) -> Optional[Token]:
|
|
61
|
+
if not self.token_file.exists():
|
|
62
|
+
return None
|
|
63
|
+
with open(self.token_file, "r") as f:
|
|
64
|
+
data = json.load(f)
|
|
65
|
+
return Token(
|
|
66
|
+
access_token=data["access_token"],
|
|
67
|
+
refresh_token=data.get("refresh_token"),
|
|
68
|
+
expires_at=int(data["expires_at"]),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def __exchange_code(self, code: str) -> Token:
|
|
72
|
+
data = {
|
|
73
|
+
"grant_type": "authorization_code",
|
|
74
|
+
"code": code,
|
|
75
|
+
"redirect_uri": f"http://localhost:{self.redirect_port}/oauth/callback",
|
|
76
|
+
"client_id": self.client_id,
|
|
77
|
+
"audience": self.audience,
|
|
78
|
+
}
|
|
79
|
+
resp = requests.post(self.token_endpoint, data=data, timeout=30)
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
return Token.from_json_response(resp.json())
|
|
82
|
+
|
|
83
|
+
def __refresh(self, token: Token) -> Token:
|
|
84
|
+
"""Refresh the token, persist it on success, and return it.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
RuntimeError: if no refresh_token is available.
|
|
88
|
+
requests.HTTPError: if the token endpoint returns an error.
|
|
89
|
+
"""
|
|
90
|
+
if not token.refresh_token:
|
|
91
|
+
raise RuntimeError("No refresh_token available for local auth refresh")
|
|
92
|
+
|
|
93
|
+
data = {
|
|
94
|
+
"grant_type": "refresh_token",
|
|
95
|
+
"refresh_token": token.refresh_token,
|
|
96
|
+
"client_id": self.client_id,
|
|
97
|
+
"audience": self.audience,
|
|
98
|
+
}
|
|
99
|
+
resp = requests.post(self.token_endpoint, data=data, timeout=30)
|
|
100
|
+
resp.raise_for_status() # will raise requests.HTTPError on non-2xx
|
|
101
|
+
|
|
102
|
+
payload = resp.json()
|
|
103
|
+
refreshed = Token(
|
|
104
|
+
access_token=payload["access_token"],
|
|
105
|
+
refresh_token=token.refresh_token,
|
|
106
|
+
expires_at=time.time() + int(payload.get("expires_in", 3600)),
|
|
107
|
+
)
|
|
108
|
+
self.__save(refreshed)
|
|
109
|
+
return refreshed
|
|
110
|
+
|
|
111
|
+
def __interactive_login(self) -> Token:
|
|
112
|
+
params = {
|
|
113
|
+
"client_id": self.client_id,
|
|
114
|
+
"response_type": "code",
|
|
115
|
+
"redirect_uri": f"http://localhost:{self.redirect_port}/oauth/callback",
|
|
116
|
+
"audience": self.audience,
|
|
117
|
+
"scope": self.scopes,
|
|
118
|
+
}
|
|
119
|
+
url = f"{self.authorization_endpoint}?{urllib.parse.urlencode(params)}"
|
|
120
|
+
|
|
121
|
+
with suppress(Exception):
|
|
122
|
+
webbrowser.open(url, new=1, autoraise=True)
|
|
123
|
+
|
|
124
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
125
|
+
code: Optional[str] = None
|
|
126
|
+
|
|
127
|
+
def do_GET(self): # noqa: N802
|
|
128
|
+
qs = urllib.parse.urlparse(self.path).query
|
|
129
|
+
data = urllib.parse.parse_qs(qs)
|
|
130
|
+
if "code" in data:
|
|
131
|
+
Handler.code = data["code"][0]
|
|
132
|
+
self.send_response(200)
|
|
133
|
+
self.end_headers()
|
|
134
|
+
self.wfile.write(b"Login complete. You can close this window.")
|
|
135
|
+
else:
|
|
136
|
+
self.send_response(400)
|
|
137
|
+
self.end_headers()
|
|
138
|
+
self.wfile.write(b"No authorization code found.")
|
|
139
|
+
|
|
140
|
+
def log_message(self, *_args, **_kwargs) -> None:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
with socketserver.TCPServer(
|
|
144
|
+
("localhost", int(self.redirect_port)), Handler
|
|
145
|
+
) as httpd:
|
|
146
|
+
httpd.timeout = 300 # 5 minutes
|
|
147
|
+
httpd.handle_request()
|
|
148
|
+
|
|
149
|
+
if not Handler.code:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"Login timed out. If your browser did not open, visit:\n{url}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
token = self.__exchange_code(Handler.code)
|
|
155
|
+
self.__save(token)
|
|
156
|
+
return token
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Authentication Token class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
DEFAULT_TOKEN_TTL_FALLBACK = 300 # seconds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Token:
|
|
16
|
+
"""Authentication token class."""
|
|
17
|
+
|
|
18
|
+
access_token: str
|
|
19
|
+
refresh_token: Optional[str]
|
|
20
|
+
expires_at: int # unix epoch seconds
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_json_response(cls, data: dict) -> Token:
|
|
24
|
+
"""Build a Token from an OAuth2 response.
|
|
25
|
+
|
|
26
|
+
Prefer `expires_at` (absolute epoch seconds), then `expires_in`
|
|
27
|
+
(relative per RFC 6749). If both are missing, try to derive `exp`
|
|
28
|
+
from a JWT access token; otherwise fall back to a short TTL.
|
|
29
|
+
"""
|
|
30
|
+
exp: Optional[int] = None
|
|
31
|
+
|
|
32
|
+
if data.get("expires_at") is not None:
|
|
33
|
+
try:
|
|
34
|
+
exp = int(float(data["expires_at"]))
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
exp = None
|
|
37
|
+
|
|
38
|
+
if exp is None and data.get("expires_in") is not None:
|
|
39
|
+
try:
|
|
40
|
+
exp = int(time.time()) + int(data["expires_in"])
|
|
41
|
+
except (TypeError, ValueError, OverflowError):
|
|
42
|
+
exp = None
|
|
43
|
+
|
|
44
|
+
if exp is None:
|
|
45
|
+
exp = cls.__jwt_exp(data.get("access_token", ""))
|
|
46
|
+
|
|
47
|
+
if exp is None:
|
|
48
|
+
exp = int(time.time()) + DEFAULT_TOKEN_TTL_FALLBACK
|
|
49
|
+
|
|
50
|
+
return cls(
|
|
51
|
+
access_token=data["access_token"],
|
|
52
|
+
refresh_token=data.get("refresh_token"),
|
|
53
|
+
expires_at=exp,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def is_expired(self, skew_seconds: int = 30) -> bool:
|
|
57
|
+
"""Treat the token as expired slightly early to account for clock skew."""
|
|
58
|
+
return time.time() >= (self.expires_at - skew_seconds)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def __jwt_exp(access_token: str) -> Optional[int]:
|
|
62
|
+
"""Best-effort extract of `exp` (epoch seconds) from a JWT access token.
|
|
63
|
+
|
|
64
|
+
We do NOT validate the signature here; this is only used as a heuristic
|
|
65
|
+
when the IdP omits both `expires_at` and `expires_in`.
|
|
66
|
+
"""
|
|
67
|
+
if not access_token:
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
parts = access_token.split(".")
|
|
71
|
+
if len(parts) != 3:
|
|
72
|
+
return None
|
|
73
|
+
payload_b64 = parts[1]
|
|
74
|
+
padding = "=" * (-len(payload_b64) % 4)
|
|
75
|
+
payload = json.loads(
|
|
76
|
+
base64.urlsafe_b64decode(payload_b64 + padding).decode()
|
|
77
|
+
)
|
|
78
|
+
if "exp" in payload:
|
|
79
|
+
return int(payload["exp"])
|
|
80
|
+
except Exception:
|
|
81
|
+
return None
|
|
82
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration authentication related things."""
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Config authentication factory.
|
|
2
|
+
|
|
3
|
+
This module normalizes the `authentication` config into a concrete model:
|
|
4
|
+
- `parse_auth_config` converts raw dicts (e.g., from YAML/env) into a model instance.
|
|
5
|
+
- `apply_auth_defaults` fills sensible defaults per auth type without inventing secrets.
|
|
6
|
+
- `check_required_auth_fields` enforces the minimum required inputs.
|
|
7
|
+
- `normalize_authentication` runs the whole pipeline.
|
|
8
|
+
|
|
9
|
+
Design notes:
|
|
10
|
+
- Auth models accept partial data (fields are Optional with None defaults).
|
|
11
|
+
- We DO NOT pass `None` explicitly when constructing models here.
|
|
12
|
+
- Required-ness is enforced centrally by `check_required_auth_fields`, not by model init.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Optional, Union, cast
|
|
16
|
+
|
|
17
|
+
from datacosmos.config.constants import (
|
|
18
|
+
DEFAULT_AUTH_AUDIENCE,
|
|
19
|
+
DEFAULT_AUTH_TOKEN_URL,
|
|
20
|
+
DEFAULT_AUTH_TYPE,
|
|
21
|
+
DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT,
|
|
22
|
+
DEFAULT_LOCAL_CACHE_FILE,
|
|
23
|
+
DEFAULT_LOCAL_REDIRECT_PORT,
|
|
24
|
+
DEFAULT_LOCAL_SCOPES,
|
|
25
|
+
DEFAULT_LOCAL_TOKEN_ENDPOINT,
|
|
26
|
+
)
|
|
27
|
+
from datacosmos.config.models.local_user_account_authentication_config import (
|
|
28
|
+
LocalUserAccountAuthenticationConfig,
|
|
29
|
+
)
|
|
30
|
+
from datacosmos.config.models.m2m_authentication_config import M2MAuthenticationConfig
|
|
31
|
+
|
|
32
|
+
AuthModel = Union[M2MAuthenticationConfig, LocalUserAccountAuthenticationConfig]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_auth_config(raw: dict | AuthModel | None) -> Optional[AuthModel]:
|
|
36
|
+
"""Turn a raw dict (e.g., from YAML) into a concrete auth model.
|
|
37
|
+
|
|
38
|
+
- If `raw` is already an auth model (M2M or local), return it unchanged.
|
|
39
|
+
- If `raw` is a dict, choose/validate the type using `raw['type']`
|
|
40
|
+
(or DEFAULT_AUTH_TYPE), then construct the corresponding model.
|
|
41
|
+
For missing fields we *may* apply non-secret defaults (endpoints, etc.).
|
|
42
|
+
"""
|
|
43
|
+
if raw is None or isinstance(
|
|
44
|
+
raw, (M2MAuthenticationConfig, LocalUserAccountAuthenticationConfig)
|
|
45
|
+
):
|
|
46
|
+
return cast(Optional[AuthModel], raw)
|
|
47
|
+
|
|
48
|
+
auth_type = _normalize_auth_type(raw.get("type") or DEFAULT_AUTH_TYPE)
|
|
49
|
+
|
|
50
|
+
if auth_type == "local":
|
|
51
|
+
return LocalUserAccountAuthenticationConfig(
|
|
52
|
+
type="local",
|
|
53
|
+
client_id=raw.get("client_id"),
|
|
54
|
+
authorization_endpoint=raw.get(
|
|
55
|
+
"authorization_endpoint", DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT
|
|
56
|
+
),
|
|
57
|
+
token_endpoint=raw.get("token_endpoint", DEFAULT_LOCAL_TOKEN_ENDPOINT),
|
|
58
|
+
redirect_port=raw.get("redirect_port", DEFAULT_LOCAL_REDIRECT_PORT),
|
|
59
|
+
scopes=raw.get("scopes", DEFAULT_LOCAL_SCOPES),
|
|
60
|
+
audience=raw.get("audience", DEFAULT_AUTH_AUDIENCE),
|
|
61
|
+
cache_file=raw.get("cache_file", DEFAULT_LOCAL_CACHE_FILE),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return M2MAuthenticationConfig(
|
|
65
|
+
type="m2m",
|
|
66
|
+
token_url=raw.get("token_url", DEFAULT_AUTH_TOKEN_URL),
|
|
67
|
+
audience=raw.get("audience", DEFAULT_AUTH_AUDIENCE),
|
|
68
|
+
client_id=raw.get("client_id"),
|
|
69
|
+
client_secret=raw.get("client_secret"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def apply_auth_defaults(auth: AuthModel | None) -> AuthModel:
|
|
74
|
+
"""Fill in any missing defaults by type (non-secret values only).
|
|
75
|
+
|
|
76
|
+
If `auth` is None, construct a default "shell" based on DEFAULT_AUTH_TYPE,
|
|
77
|
+
without passing None for unknown credentials.
|
|
78
|
+
"""
|
|
79
|
+
if auth is None:
|
|
80
|
+
default_type = _normalize_auth_type(DEFAULT_AUTH_TYPE)
|
|
81
|
+
if default_type == "local":
|
|
82
|
+
auth = LocalUserAccountAuthenticationConfig(
|
|
83
|
+
type="local",
|
|
84
|
+
authorization_endpoint=DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT,
|
|
85
|
+
token_endpoint=DEFAULT_LOCAL_TOKEN_ENDPOINT,
|
|
86
|
+
redirect_port=DEFAULT_LOCAL_REDIRECT_PORT,
|
|
87
|
+
scopes=DEFAULT_LOCAL_SCOPES,
|
|
88
|
+
audience=DEFAULT_AUTH_AUDIENCE,
|
|
89
|
+
cache_file=DEFAULT_LOCAL_CACHE_FILE,
|
|
90
|
+
)
|
|
91
|
+
else: # "m2m"
|
|
92
|
+
auth = M2MAuthenticationConfig(
|
|
93
|
+
type="m2m",
|
|
94
|
+
token_url=DEFAULT_AUTH_TOKEN_URL,
|
|
95
|
+
audience=DEFAULT_AUTH_AUDIENCE,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if isinstance(auth, M2MAuthenticationConfig):
|
|
99
|
+
auth.type = auth.type or "m2m"
|
|
100
|
+
auth.token_url = auth.token_url or DEFAULT_AUTH_TOKEN_URL
|
|
101
|
+
auth.audience = auth.audience or DEFAULT_AUTH_AUDIENCE
|
|
102
|
+
return auth
|
|
103
|
+
|
|
104
|
+
# Local defaults (Pydantic already coerces types; only set when missing)
|
|
105
|
+
auth.type = auth.type or "local"
|
|
106
|
+
auth.authorization_endpoint = (
|
|
107
|
+
auth.authorization_endpoint or DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT
|
|
108
|
+
)
|
|
109
|
+
auth.token_endpoint = auth.token_endpoint or DEFAULT_LOCAL_TOKEN_ENDPOINT
|
|
110
|
+
if auth.redirect_port is None:
|
|
111
|
+
auth.redirect_port = DEFAULT_LOCAL_REDIRECT_PORT
|
|
112
|
+
auth.scopes = auth.scopes or DEFAULT_LOCAL_SCOPES
|
|
113
|
+
auth.audience = auth.audience or DEFAULT_AUTH_AUDIENCE
|
|
114
|
+
auth.cache_file = auth.cache_file or DEFAULT_LOCAL_CACHE_FILE
|
|
115
|
+
return auth
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check_required_auth_fields(auth: AuthModel) -> None:
|
|
119
|
+
"""Enforce required fields per auth type.
|
|
120
|
+
|
|
121
|
+
- m2m requires client_id and client_secret.
|
|
122
|
+
- local requires client_id.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(auth, M2MAuthenticationConfig):
|
|
125
|
+
missing = [f for f in ("client_id", "client_secret") if not getattr(auth, f)]
|
|
126
|
+
if missing:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Missing required authentication fields for m2m: {', '.join(missing)}"
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if isinstance(auth, LocalUserAccountAuthenticationConfig):
|
|
133
|
+
if not auth.client_id:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"Missing required authentication field for local: client_id"
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
raise ValueError(f"Unsupported authentication model: {type(auth).__name__}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize_authentication(raw: dict | AuthModel | None) -> AuthModel:
|
|
143
|
+
"""End-to-end auth normalization: parse -> apply defaults -> required-field checks."""
|
|
144
|
+
model = parse_auth_config(raw)
|
|
145
|
+
model = apply_auth_defaults(model)
|
|
146
|
+
check_required_auth_fields(model)
|
|
147
|
+
return model
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalize_auth_type(value: str) -> str:
|
|
151
|
+
"""Return a normalized auth type or raise for unsupported values."""
|
|
152
|
+
v = (value or "").strip().lower()
|
|
153
|
+
if v in {"m2m", "local"}:
|
|
154
|
+
return v
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Unsupported authentication type: {value!r}. Expected 'm2m' or 'local'."
|
|
157
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Configuration module for the Datacosmos SDK.
|
|
2
|
+
|
|
3
|
+
Handles configuration management using Pydantic and Pydantic Settings.
|
|
4
|
+
It loads default values, allows overrides via YAML configuration files,
|
|
5
|
+
and supports environment variable-based overrides.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import field_validator
|
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
|
+
|
|
13
|
+
from datacosmos.config.auth.factory import normalize_authentication, parse_auth_config
|
|
14
|
+
from datacosmos.config.constants import (
|
|
15
|
+
DEFAULT_CONFIG_YAML,
|
|
16
|
+
DEFAULT_STAC,
|
|
17
|
+
DEFAULT_STORAGE,
|
|
18
|
+
)
|
|
19
|
+
from datacosmos.config.loaders.yaml_source import yaml_settings_source
|
|
20
|
+
from datacosmos.config.models.authentication_config import AuthenticationConfig
|
|
21
|
+
from datacosmos.config.models.url import URL
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Config(BaseSettings):
|
|
25
|
+
"""Centralized configuration for the Datacosmos SDK."""
|
|
26
|
+
|
|
27
|
+
model_config = SettingsConfigDict(
|
|
28
|
+
env_nested_delimiter="__",
|
|
29
|
+
nested_model_default_partial_update=True,
|
|
30
|
+
extra="allow",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
authentication: Optional[AuthenticationConfig] = None
|
|
34
|
+
stac: URL | None = None
|
|
35
|
+
datacosmos_cloud_storage: URL | None = None
|
|
36
|
+
datacosmos_public_cloud_storage: URL | None = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def settings_customise_sources(cls, *args, **kwargs):
|
|
40
|
+
"""Sets customised sources."""
|
|
41
|
+
init_settings = kwargs.get("init_settings") or (
|
|
42
|
+
args[1] if len(args) > 1 else None
|
|
43
|
+
)
|
|
44
|
+
env_settings = kwargs.get("env_settings") or (
|
|
45
|
+
args[2] if len(args) > 2 else None
|
|
46
|
+
)
|
|
47
|
+
dotenv_settings = kwargs.get("dotenv_settings") or (
|
|
48
|
+
args[3] if len(args) > 3 else None
|
|
49
|
+
)
|
|
50
|
+
file_secret_settings = kwargs.get("file_secret_settings") or (
|
|
51
|
+
args[4] if len(args) > 4 else None
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
sources = [
|
|
55
|
+
init_settings,
|
|
56
|
+
yaml_settings_source(DEFAULT_CONFIG_YAML),
|
|
57
|
+
env_settings,
|
|
58
|
+
dotenv_settings,
|
|
59
|
+
file_secret_settings,
|
|
60
|
+
]
|
|
61
|
+
return tuple(s for s in sources if s is not None)
|
|
62
|
+
|
|
63
|
+
@field_validator("authentication", mode="before")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _parse_authentication(cls, raw):
|
|
66
|
+
if raw is None:
|
|
67
|
+
return None
|
|
68
|
+
from datacosmos.config.models.local_user_account_authentication_config import (
|
|
69
|
+
LocalUserAccountAuthenticationConfig,
|
|
70
|
+
)
|
|
71
|
+
from datacosmos.config.models.m2m_authentication_config import (
|
|
72
|
+
M2MAuthenticationConfig,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if isinstance(
|
|
76
|
+
raw, (M2MAuthenticationConfig, LocalUserAccountAuthenticationConfig)
|
|
77
|
+
):
|
|
78
|
+
return raw
|
|
79
|
+
if isinstance(raw, dict):
|
|
80
|
+
return parse_auth_config(raw)
|
|
81
|
+
return raw
|
|
82
|
+
|
|
83
|
+
@field_validator("authentication", mode="after")
|
|
84
|
+
@classmethod
|
|
85
|
+
def _validate_authentication(cls, auth: Optional[AuthenticationConfig]):
|
|
86
|
+
return normalize_authentication(auth)
|
|
87
|
+
|
|
88
|
+
@field_validator("stac", mode="before")
|
|
89
|
+
@classmethod
|
|
90
|
+
def _default_stac(cls, value: URL | None) -> URL:
|
|
91
|
+
return value or URL(**DEFAULT_STAC)
|
|
92
|
+
|
|
93
|
+
@field_validator("datacosmos_cloud_storage", mode="before")
|
|
94
|
+
@classmethod
|
|
95
|
+
def _default_cloud_storage(cls, value: URL | None) -> URL:
|
|
96
|
+
return value or URL(**DEFAULT_STORAGE)
|
|
97
|
+
|
|
98
|
+
@field_validator("datacosmos_public_cloud_storage", mode="before")
|
|
99
|
+
@classmethod
|
|
100
|
+
def _default_public_cloud_storage(cls, value: URL | None) -> URL:
|
|
101
|
+
return value or URL(**DEFAULT_STORAGE)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Config constants."""
|
|
2
|
+
|
|
3
|
+
# ---- Authentication defaults ----
|
|
4
|
+
DEFAULT_AUTH_TYPE = "m2m"
|
|
5
|
+
|
|
6
|
+
# M2M
|
|
7
|
+
DEFAULT_AUTH_TOKEN_URL = "https://login.open-cosmos.com/oauth/token"
|
|
8
|
+
DEFAULT_AUTH_AUDIENCE = "https://beeapp.open-cosmos.com"
|
|
9
|
+
|
|
10
|
+
# Local (interactive)
|
|
11
|
+
DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT = "https://login.open-cosmos.com/authorize"
|
|
12
|
+
DEFAULT_LOCAL_TOKEN_ENDPOINT = DEFAULT_AUTH_TOKEN_URL
|
|
13
|
+
DEFAULT_LOCAL_REDIRECT_PORT = 8765
|
|
14
|
+
DEFAULT_LOCAL_SCOPES = "openid profile email offline_access"
|
|
15
|
+
DEFAULT_LOCAL_CACHE_FILE = "~/.datacosmos/token_cache.json"
|
|
16
|
+
|
|
17
|
+
# ---- Service URLs ----
|
|
18
|
+
DEFAULT_STAC = dict(
|
|
19
|
+
protocol="https", host="app.open-cosmos.com", port=443, path="/api/data/v0/stac"
|
|
20
|
+
)
|
|
21
|
+
DEFAULT_STORAGE = dict(
|
|
22
|
+
protocol="https", host="app.open-cosmos.com", port=443, path="/api/data/v0/storage"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# ---- Config file path ----
|
|
26
|
+
DEFAULT_CONFIG_YAML = "config/config.yaml"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""YAML settings source for pydantic-settings.
|
|
2
|
+
|
|
3
|
+
This module provides a tiny helper to inject a YAML file as a configuration
|
|
4
|
+
source for `pydantic-settings` (v2.x). It returns a callable compatible with
|
|
5
|
+
`BaseSettings.settings_customise_sources`, placed wherever you want in the
|
|
6
|
+
precedence chain.
|
|
7
|
+
|
|
8
|
+
- If the file is missing, the source returns an empty dict.
|
|
9
|
+
- Empty-ish values (`None`, empty string, empty list) are dropped so they don't
|
|
10
|
+
overwrite values coming from later sources (e.g., environment variables).
|
|
11
|
+
- The returned callable accepts `*args, **kwargs` to be version-agnostic across
|
|
12
|
+
pydantic-settings minor releases (some pass positional args, others keywords).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any, Callable, Dict
|
|
16
|
+
|
|
17
|
+
# A callable that returns a mapping of settings values when invoked by pydantic-settings.
|
|
18
|
+
SettingsSourceCallable = Callable[..., Dict[str, Any]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def yaml_settings_source(file_path: str) -> SettingsSourceCallable:
|
|
22
|
+
"""Create a pydantic-settings-compatible source that reads from a YAML file.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
file_path : str
|
|
27
|
+
Absolute or relative path to the YAML file to load.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
SettingsSourceCallable
|
|
32
|
+
A callable that, when invoked by pydantic-settings, returns a dict
|
|
33
|
+
of settings loaded from the YAML file. If the file does not exist,
|
|
34
|
+
an empty dict is returned. Keys with empty/None values are omitted
|
|
35
|
+
so later sources (e.g., env vars) can provide effective overrides.
|
|
36
|
+
|
|
37
|
+
Notes
|
|
38
|
+
-----
|
|
39
|
+
The returned callable accepts arbitrary `*args` and `**kwargs` to stay
|
|
40
|
+
compatible with different pydantic-settings 2.x calling conventions.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def _source(*_args: Any, **_kwargs: Any) -> Dict[str, Any]:
|
|
44
|
+
"""Load and sanitize YAML content for use as a settings source.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
dict
|
|
49
|
+
A dictionary of settings. If the YAML file is missing, `{}`.
|
|
50
|
+
Values that are `None`, empty strings, or empty lists are dropped.
|
|
51
|
+
"""
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
import yaml
|
|
55
|
+
|
|
56
|
+
if not os.path.exists(file_path):
|
|
57
|
+
return {}
|
|
58
|
+
with open(file_path, "r") as f:
|
|
59
|
+
data = yaml.safe_load(f) or {}
|
|
60
|
+
return {k: v for k, v in data.items() if v not in (None, "", [])}
|
|
61
|
+
|
|
62
|
+
return _source
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Configuration for local user account authentication.
|
|
2
|
+
|
|
3
|
+
When this is chosen, the user will be prompted to log in using their OPS credentials.
|
|
4
|
+
This will be used for running scripts locally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LocalUserAccountAuthenticationConfig(BaseModel):
|
|
13
|
+
"""Configuration for local user account authentication.
|
|
14
|
+
|
|
15
|
+
When this is chosen, the user will be prompted to log in using their OPS credentials.
|
|
16
|
+
This will be used for running scripts locally. Required fields are enforced by `normalize_authentication` after merge.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
type: Literal["local"] = "local"
|
|
22
|
+
client_id: Optional[str] = None
|
|
23
|
+
authorization_endpoint: Optional[str] = None
|
|
24
|
+
token_endpoint: Optional[str] = None
|
|
25
|
+
redirect_port: Optional[int] = None
|
|
26
|
+
scopes: Optional[str] = None
|
|
27
|
+
audience: Optional[str] = None
|
|
28
|
+
cache_file: Optional[str] = None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Module for configuring machine-to-machine (M2M) authentication.
|
|
2
|
+
|
|
3
|
+
Used when running scripts in the cluster that require automated authentication
|
|
4
|
+
without user interaction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class M2MAuthenticationConfig(BaseModel):
|
|
13
|
+
"""Configuration for machine-to-machine authentication.
|
|
14
|
+
|
|
15
|
+
This is used when running scripts in the cluster that require authentication
|
|
16
|
+
with client credentials. Required fields are enforced by `normalize_authentication` after merge.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
type: Literal["m2m"] = "m2m"
|
|
22
|
+
client_id: Optional[str] = None
|
|
23
|
+
client_secret: Optional[str] = None
|
|
24
|
+
token_url: Optional[str] = None
|
|
25
|
+
audience: Optional[str] = None
|