datacosmos 0.0.11__py3-none-any.whl → 0.0.13__py3-none-any.whl

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.

@@ -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
+ )
@@ -5,15 +5,19 @@ It loads default values, allows overrides via YAML configuration files,
5
5
  and supports environment variable-based overrides.
6
6
  """
7
7
 
8
- import os
9
- from typing import ClassVar, Optional
8
+ from typing import Optional
10
9
 
11
- import yaml
12
10
  from pydantic import field_validator
13
11
  from pydantic_settings import BaseSettings, SettingsConfigDict
14
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
15
20
  from datacosmos.config.models.authentication_config import AuthenticationConfig
16
- from datacosmos.config.models.m2m_authentication_config import M2MAuthenticationConfig
17
21
  from datacosmos.config.models.url import URL
18
22
 
19
23
 
@@ -27,196 +31,71 @@ class Config(BaseSettings):
27
31
  )
28
32
 
29
33
  authentication: Optional[AuthenticationConfig] = None
30
- stac: Optional[URL] = None
31
- datacosmos_cloud_storage: Optional[URL] = None
32
- datacosmos_public_cloud_storage: Optional[URL] = None
33
-
34
- DEFAULT_AUTH_TYPE: ClassVar[str] = "m2m"
35
- DEFAULT_AUTH_TOKEN_URL: ClassVar[str] = "https://login.open-cosmos.com/oauth/token"
36
- DEFAULT_AUTH_AUDIENCE: ClassVar[str] = "https://beeapp.open-cosmos.com"
37
-
38
- @classmethod
39
- def from_yaml(cls, file_path: str = "config/config.yaml") -> "Config":
40
- """Load configuration from a YAML file and override defaults.
41
-
42
- Args:
43
- file_path (str): The path to the YAML configuration file.
44
-
45
- Returns:
46
- Config: An instance of the Config class with loaded settings.
47
- """
48
- config_data: dict = {}
49
- if os.path.exists(file_path):
50
- with open(file_path, "r") as f:
51
- yaml_data = yaml.safe_load(f) or {}
52
- # Remove empty values from YAML to avoid overwriting with `None`
53
- config_data = {
54
- key: value
55
- for key, value in yaml_data.items()
56
- if value not in [None, ""]
57
- }
58
-
59
- return cls(**config_data)
34
+ stac: URL | None = None
35
+ datacosmos_cloud_storage: URL | None = None
36
+ datacosmos_public_cloud_storage: URL | None = None
60
37
 
61
38
  @classmethod
62
- def from_env(cls) -> "Config":
63
- """Load configuration from environment variables.
64
-
65
- Returns:
66
- Config: An instance of the Config class with settings loaded from environment variables.
67
- """
68
- authentication_config = M2MAuthenticationConfig(
69
- type=os.getenv("OC_AUTH_TYPE", cls.DEFAULT_AUTH_TYPE),
70
- client_id=os.getenv("OC_AUTH_CLIENT_ID"),
71
- client_secret=os.getenv("OC_AUTH_CLIENT_SECRET"),
72
- token_url=os.getenv("OC_AUTH_TOKEN_URL", cls.DEFAULT_AUTH_TOKEN_URL),
73
- audience=os.getenv("OC_AUTH_AUDIENCE", cls.DEFAULT_AUTH_AUDIENCE),
74
- )
75
-
76
- stac_config = URL(
77
- protocol=os.getenv("OC_STAC_PROTOCOL", "https"),
78
- host=os.getenv("OC_STAC_HOST", "app.open-cosmos.com"),
79
- port=int(os.getenv("OC_STAC_PORT", "443")),
80
- path=os.getenv("OC_STAC_PATH", "/api/data/v0/stac"),
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
81
43
  )
82
-
83
- datacosmos_cloud_storage_config = URL(
84
- protocol=os.getenv("DC_CLOUD_STORAGE_PROTOCOL", "https"),
85
- host=os.getenv("DC_CLOUD_STORAGE_HOST", "app.open-cosmos.com"),
86
- port=int(os.getenv("DC_CLOUD_STORAGE_PORT", "443")),
87
- path=os.getenv("DC_CLOUD_STORAGE_PATH", "/api/data/v0/storage"),
44
+ env_settings = kwargs.get("env_settings") or (
45
+ args[2] if len(args) > 2 else None
88
46
  )
89
-
90
- datacosmos_public_cloud_storage_config = URL(
91
- protocol=os.getenv("DC_PUBLIC_CLOUD_STORAGE_PROTOCOL", "https"),
92
- host=os.getenv("DC_PUBLIC_CLOUD_STORAGE_HOST", "app.open-cosmos.com"),
93
- port=int(os.getenv("DC_PUBLIC_CLOUD_STORAGE_PORT", "443")),
94
- path=os.getenv("DC_PUBLIC_CLOUD_STORAGE_PATH", "/api/data/v0/storage"),
47
+ dotenv_settings = kwargs.get("dotenv_settings") or (
48
+ args[3] if len(args) > 3 else None
95
49
  )
96
-
97
- return cls(
98
- authentication=authentication_config,
99
- stac=stac_config,
100
- datacosmos_cloud_storage=datacosmos_cloud_storage_config,
101
- datacosmos_public_cloud_storage=datacosmos_public_cloud_storage_config,
50
+ file_secret_settings = kwargs.get("file_secret_settings") or (
51
+ args[4] if len(args) > 4 else None
102
52
  )
103
53
 
104
- @field_validator("authentication", mode="after")
105
- @classmethod
106
- def validate_authentication(
107
- cls, auth: Optional[M2MAuthenticationConfig]
108
- ) -> M2MAuthenticationConfig:
109
- """Ensure authentication is provided and apply defaults.
110
-
111
- Args:
112
- auth (Optional[M2MAuthenticationConfig]): The authentication config.
113
-
114
- Returns:
115
- M2MAuthenticationConfig: The validated authentication configuration.
116
-
117
- Raises:
118
- ValueError: If authentication is missing or required fields are not set.
119
- """
120
- if auth is None:
121
- auth = cls.apply_auth_defaults(M2MAuthenticationConfig())
122
- else:
123
- auth = cls.apply_auth_defaults(auth)
124
-
125
- cls.check_required_auth_fields(auth)
126
- return auth
127
-
128
- @staticmethod
129
- def apply_auth_defaults(auth: M2MAuthenticationConfig) -> M2MAuthenticationConfig:
130
- """Apply default authentication values if they are missing."""
131
- auth.type = auth.type or Config.DEFAULT_AUTH_TYPE
132
- auth.token_url = auth.token_url or Config.DEFAULT_AUTH_TOKEN_URL
133
- auth.audience = auth.audience or Config.DEFAULT_AUTH_AUDIENCE
134
- return auth
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)
135
62
 
63
+ @field_validator("authentication", mode="before")
136
64
  @classmethod
137
- def parse_auth_config(cls, auth_data: dict) -> M2MAuthenticationConfig:
138
- """Parse authentication config from a dictionary."""
139
- return M2MAuthenticationConfig(
140
- type=auth_data.get("type", cls.DEFAULT_AUTH_TYPE),
141
- token_url=auth_data.get("token_url", cls.DEFAULT_AUTH_TOKEN_URL),
142
- audience=auth_data.get("audience", cls.DEFAULT_AUTH_AUDIENCE),
143
- client_id=auth_data.get("client_id"),
144
- client_secret=auth_data.get("client_secret"),
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,
145
73
  )
146
74
 
147
- @staticmethod
148
- def check_required_auth_fields(auth: M2MAuthenticationConfig):
149
- """Ensure required fields (client_id, client_secret) are provided."""
150
- missing_fields = [
151
- field
152
- for field in ("client_id", "client_secret")
153
- if not getattr(auth, field)
154
- ]
155
- if missing_fields:
156
- raise ValueError(
157
- f"Missing required authentication fields: {', '.join(missing_fields)}"
158
- )
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)
159
87
 
160
88
  @field_validator("stac", mode="before")
161
89
  @classmethod
162
- def validate_stac(cls, stac_config: Optional[URL]) -> URL:
163
- """Ensure STAC configuration has a default if not explicitly set.
164
-
165
- Args:
166
- stac_config (Optional[URL]): The STAC config to validate.
167
-
168
- Returns:
169
- URL: The validated STAC configuration.
170
- """
171
- if stac_config is None:
172
- return URL(
173
- protocol="https",
174
- host="app.open-cosmos.com",
175
- port=443,
176
- path="/api/data/v0/stac",
177
- )
178
- return stac_config
90
+ def _default_stac(cls, value: URL | None) -> URL:
91
+ return value or URL(**DEFAULT_STAC)
179
92
 
180
93
  @field_validator("datacosmos_cloud_storage", mode="before")
181
94
  @classmethod
182
- def validate_datacosmos_cloud_storage(
183
- cls, datacosmos_cloud_storage_config: Optional[URL]
184
- ) -> URL:
185
- """Ensure datacosmos cloud storage configuration has a default if not explicitly set.
186
-
187
- Args:
188
- datacosmos_cloud_storage_config (Optional[URL]): The datacosmos cloud storage config to validate.
189
-
190
- Returns:
191
- URL: The validated datacosmos cloud storage configuration.
192
- """
193
- if datacosmos_cloud_storage_config is None:
194
- return URL(
195
- protocol="https",
196
- host="app.open-cosmos.com",
197
- port=443,
198
- path="/api/data/v0/storage",
199
- )
200
- return datacosmos_cloud_storage_config
95
+ def _default_cloud_storage(cls, value: URL | None) -> URL:
96
+ return value or URL(**DEFAULT_STORAGE)
201
97
 
202
98
  @field_validator("datacosmos_public_cloud_storage", mode="before")
203
99
  @classmethod
204
- def validate_datacosmos_public_cloud_storage(
205
- cls, datacosmos_public_cloud_storage_config: Optional[URL]
206
- ) -> URL:
207
- """Ensure datacosmos cloud storage configuration has a default if not explicitly set.
208
-
209
- Args:
210
- datacosmos_public_cloud_storage_config (Optional[URL]): The datacosmos public cloud storage config to validate.
211
-
212
- Returns:
213
- URL: The validated datacosmos public cloud storage configuration.
214
- """
215
- if datacosmos_public_cloud_storage_config is None:
216
- return URL(
217
- protocol="https",
218
- host="app.open-cosmos.com",
219
- port=443,
220
- path="/api/data/v0/storage",
221
- )
222
- return datacosmos_public_cloud_storage_config
100
+ def _default_public_cloud_storage(cls, value: URL | None) -> URL:
101
+ return value or URL(**DEFAULT_STORAGE)