datacosmos 0.0.11__tar.gz → 0.0.13__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.

Potentially problematic release.


This version of datacosmos might be problematic. Click here for more details.

Files changed (69) hide show
  1. {datacosmos-0.0.11 → datacosmos-0.0.13}/PKG-INFO +2 -1
  2. datacosmos-0.0.13/datacosmos/auth/__init__.py +1 -0
  3. datacosmos-0.0.13/datacosmos/auth/local_token_fetcher.py +156 -0
  4. datacosmos-0.0.13/datacosmos/auth/token.py +82 -0
  5. datacosmos-0.0.13/datacosmos/config/auth/__init__.py +1 -0
  6. datacosmos-0.0.13/datacosmos/config/auth/factory.py +157 -0
  7. datacosmos-0.0.13/datacosmos/config/config.py +101 -0
  8. datacosmos-0.0.13/datacosmos/config/constants.py +26 -0
  9. datacosmos-0.0.13/datacosmos/config/loaders/yaml_source.py +62 -0
  10. datacosmos-0.0.13/datacosmos/config/models/local_user_account_authentication_config.py +28 -0
  11. datacosmos-0.0.13/datacosmos/config/models/m2m_authentication_config.py +25 -0
  12. datacosmos-0.0.13/datacosmos/datacosmos_client.py +250 -0
  13. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/stac_client.py +2 -1
  14. datacosmos-0.0.13/datacosmos/stac/storage/dataclasses/upload_path.py +42 -0
  15. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/storage/storage_client.py +2 -0
  16. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/storage/uploader.py +42 -13
  17. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos.egg-info/PKG-INFO +2 -1
  18. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos.egg-info/SOURCES.txt +7 -0
  19. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos.egg-info/requires.txt +1 -0
  20. {datacosmos-0.0.11 → datacosmos-0.0.13}/pyproject.toml +2 -1
  21. datacosmos-0.0.11/datacosmos/config/config.py +0 -222
  22. datacosmos-0.0.11/datacosmos/config/models/local_user_account_authentication_config.py +0 -26
  23. datacosmos-0.0.11/datacosmos/config/models/m2m_authentication_config.py +0 -27
  24. datacosmos-0.0.11/datacosmos/datacosmos_client.py +0 -152
  25. datacosmos-0.0.11/datacosmos/stac/storage/dataclasses/upload_path.py +0 -63
  26. {datacosmos-0.0.11 → datacosmos-0.0.13}/LICENSE.md +0 -0
  27. {datacosmos-0.0.11 → datacosmos-0.0.13}/README.md +0 -0
  28. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/__init__.py +0 -0
  29. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/config/__init__.py +0 -0
  30. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/config/models/__init__.py +0 -0
  31. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/config/models/authentication_config.py +0 -0
  32. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/config/models/no_authentication_config.py +0 -0
  33. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/config/models/url.py +0 -0
  34. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/exceptions/__init__.py +0 -0
  35. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/exceptions/datacosmos_exception.py +0 -0
  36. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/__init__.py +0 -0
  37. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/collection/__init__.py +0 -0
  38. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/collection/collection_client.py +0 -0
  39. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/collection/models/__init__.py +0 -0
  40. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/collection/models/collection_update.py +0 -0
  41. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/constants/__init__.py +0 -0
  42. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/constants/satellite_name_mapping.py +0 -0
  43. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/enums/__init__.py +0 -0
  44. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/enums/processing_level.py +0 -0
  45. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/enums/product_type.py +0 -0
  46. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/enums/season.py +0 -0
  47. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/__init__.py +0 -0
  48. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/item_client.py +0 -0
  49. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/models/__init__.py +0 -0
  50. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/models/asset.py +0 -0
  51. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/models/catalog_search_parameters.py +0 -0
  52. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/models/datacosmos_item.py +0 -0
  53. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/models/eo_band.py +0 -0
  54. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/models/item_update.py +0 -0
  55. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/item/models/raster_band.py +0 -0
  56. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/storage/__init__.py +0 -0
  57. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/storage/dataclasses/__init__.py +0 -0
  58. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/stac/storage/storage_base.py +0 -0
  59. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/utils/__init__.py +0 -0
  60. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/utils/http_response/__init__.py +0 -0
  61. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/utils/http_response/check_api_response.py +0 -0
  62. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/utils/http_response/models/__init__.py +0 -0
  63. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/utils/http_response/models/datacosmos_error.py +0 -0
  64. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/utils/http_response/models/datacosmos_response.py +0 -0
  65. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos/utils/url.py +0 -0
  66. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos.egg-info/dependency_links.txt +0 -0
  67. {datacosmos-0.0.11 → datacosmos-0.0.13}/datacosmos.egg-info/top_level.txt +0 -0
  68. {datacosmos-0.0.11 → datacosmos-0.0.13}/setup.cfg +0 -0
  69. {datacosmos-0.0.11 → datacosmos-0.0.13}/tests/test_pass.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datacosmos
3
- Version: 0.0.11
3
+ Version: 0.0.13
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