datacosmos 0.0.12__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.
- datacosmos/auth/__init__.py +1 -0
- datacosmos/auth/local_token_fetcher.py +156 -0
- datacosmos/auth/token.py +82 -0
- datacosmos/config/auth/__init__.py +1 -0
- datacosmos/config/auth/factory.py +157 -0
- datacosmos/config/config.py +56 -177
- datacosmos/config/constants.py +26 -0
- datacosmos/config/loaders/yaml_source.py +62 -0
- datacosmos/config/models/local_user_account_authentication_config.py +13 -11
- datacosmos/config/models/m2m_authentication_config.py +9 -11
- datacosmos/datacosmos_client.py +153 -55
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.13.dist-info}/METADATA +2 -1
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.13.dist-info}/RECORD +16 -9
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.13.dist-info}/WHEEL +0 -0
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.13.dist-info}/licenses/LICENSE.md +0 -0
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.13.dist-info}/top_level.txt +0 -0
|
@@ -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
|
datacosmos/auth/token.py
ADDED
|
@@ -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
|
+
)
|
datacosmos/config/config.py
CHANGED
|
@@ -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
|
|
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:
|
|
31
|
-
datacosmos_cloud_storage:
|
|
32
|
-
datacosmos_public_cloud_storage:
|
|
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
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
163
|
-
|
|
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
|
|
183
|
-
|
|
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
|
|
205
|
-
|
|
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)
|
|
@@ -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
|
|
@@ -4,23 +4,25 @@ When this is chosen, the user will be prompted to log in using their OPS credent
|
|
|
4
4
|
This will be used for running scripts locally.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from typing import Literal
|
|
7
|
+
from typing import Literal, Optional
|
|
8
8
|
|
|
9
|
-
from pydantic import BaseModel
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class LocalUserAccountAuthenticationConfig(BaseModel):
|
|
13
13
|
"""Configuration for local user account authentication.
|
|
14
14
|
|
|
15
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.
|
|
16
|
+
This will be used for running scripts locally. Required fields are enforced by `normalize_authentication` after merge.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
@@ -4,24 +4,22 @@ Used when running scripts in the cluster that require automated authentication
|
|
|
4
4
|
without user interaction.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from typing import Literal
|
|
7
|
+
from typing import Literal, Optional
|
|
8
8
|
|
|
9
|
-
from pydantic import BaseModel,
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class M2MAuthenticationConfig(BaseModel):
|
|
13
13
|
"""Configuration for machine-to-machine authentication.
|
|
14
14
|
|
|
15
15
|
This is used when running scripts in the cluster that require authentication
|
|
16
|
-
with client credentials.
|
|
16
|
+
with client credentials. Required fields are enforced by `normalize_authentication` after merge.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
DEFAULT_TOKEN_URL: str = "https://login.open-cosmos.com/oauth/token"
|
|
21
|
-
DEFAULT_AUDIENCE: str = "https://beeapp.open-cosmos.com"
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
20
|
|
|
23
|
-
type: Literal["m2m"] =
|
|
24
|
-
client_id: str
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
datacosmos/datacosmos_client.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Client to interact with the Datacosmos API with authentication and request handling."""
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
from typing import Any, Optional
|
|
5
6
|
|
|
6
7
|
import requests
|
|
@@ -23,86 +24,183 @@ class DatacosmosClient:
|
|
|
23
24
|
"""Initialize the DatacosmosClient.
|
|
24
25
|
|
|
25
26
|
Args:
|
|
26
|
-
config
|
|
27
|
-
http_session
|
|
27
|
+
config: SDK configuration (if omitted, Config() loads YAML + env).
|
|
28
|
+
http_session: Pre-authenticated session (OAuth2Session or requests.Session
|
|
29
|
+
with 'Authorization: Bearer ...').
|
|
28
30
|
"""
|
|
31
|
+
self.config = self._coerce_config(config)
|
|
32
|
+
self.token: Optional[str] = None
|
|
33
|
+
self.token_expiry: Optional[datetime] = None
|
|
34
|
+
|
|
29
35
|
if http_session is not None:
|
|
30
|
-
self.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
self._init_with_injected_session(http_session)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
self._owns_session = True
|
|
40
|
+
self._http_client = self._authenticate_and_initialize_client()
|
|
41
|
+
|
|
42
|
+
# --------------------------- init helpers ---------------------------
|
|
43
|
+
|
|
44
|
+
def _coerce_config(self, cfg: Optional[Config | Any]) -> Config:
|
|
45
|
+
"""Normalize various config inputs into a Config instance."""
|
|
46
|
+
if cfg is None:
|
|
47
|
+
return Config()
|
|
48
|
+
if isinstance(cfg, Config):
|
|
49
|
+
return cfg
|
|
50
|
+
if isinstance(cfg, dict):
|
|
51
|
+
return Config(**cfg)
|
|
52
|
+
try:
|
|
53
|
+
return Config.model_validate(cfg) # pydantic v2
|
|
54
|
+
except Exception as e:
|
|
55
|
+
raise DatacosmosException(
|
|
56
|
+
"Invalid config provided to DatacosmosClient"
|
|
57
|
+
) from e
|
|
58
|
+
|
|
59
|
+
def _init_with_injected_session(
|
|
60
|
+
self, http_session: requests.Session | OAuth2Session
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Adopt a caller-provided session and extract token/expiry."""
|
|
63
|
+
self._http_client = http_session
|
|
64
|
+
self._owns_session = False
|
|
65
|
+
|
|
66
|
+
token_data = self._extract_token_data(http_session)
|
|
67
|
+
self.token = token_data.get("access_token")
|
|
68
|
+
if not self.token:
|
|
69
|
+
raise DatacosmosException(
|
|
70
|
+
"Failed to extract access token from injected session"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.token_expiry = self._compute_expiry(
|
|
74
|
+
token_data.get("expires_at"),
|
|
75
|
+
token_data.get("expires_in"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _extract_token_data(
|
|
79
|
+
self, http_session: requests.Session | OAuth2Session
|
|
80
|
+
) -> dict:
|
|
81
|
+
"""Return {'access_token', 'expires_at'?, 'expires_in'?} from the session."""
|
|
82
|
+
if isinstance(http_session, OAuth2Session):
|
|
83
|
+
return getattr(http_session, "token", {}) or {}
|
|
84
|
+
|
|
85
|
+
if isinstance(http_session, requests.Session):
|
|
86
|
+
auth_header = http_session.headers.get("Authorization", "")
|
|
87
|
+
if not auth_header.startswith("Bearer "):
|
|
42
88
|
raise DatacosmosException(
|
|
43
|
-
|
|
89
|
+
"Injected requests.Session must include a 'Bearer' token in its headers"
|
|
44
90
|
)
|
|
91
|
+
return {"access_token": auth_header.split(" ", 1)[1]}
|
|
92
|
+
|
|
93
|
+
raise DatacosmosException(f"Unsupported session type: {type(http_session)}")
|
|
94
|
+
|
|
95
|
+
def _compute_expiry(
|
|
96
|
+
self,
|
|
97
|
+
expires_at: Optional[datetime | int | float],
|
|
98
|
+
expires_in: Optional[int | float],
|
|
99
|
+
) -> Optional[datetime]:
|
|
100
|
+
"""Normalize expiry inputs to an absolute UTC datetime (or None)."""
|
|
101
|
+
if isinstance(expires_at, datetime):
|
|
102
|
+
return expires_at
|
|
103
|
+
if isinstance(expires_at, (int, float)):
|
|
104
|
+
return datetime.fromtimestamp(expires_at, tz=timezone.utc)
|
|
105
|
+
if expires_in is not None:
|
|
45
106
|
try:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
except Exception:
|
|
51
|
-
raise DatacosmosException(
|
|
52
|
-
"Failed to extract token from injected session"
|
|
53
|
-
)
|
|
107
|
+
return datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
|
|
108
|
+
except (TypeError, ValueError):
|
|
109
|
+
return None
|
|
110
|
+
return None
|
|
54
111
|
|
|
55
|
-
|
|
56
|
-
else:
|
|
57
|
-
if config:
|
|
58
|
-
self.config = config
|
|
59
|
-
else:
|
|
60
|
-
try:
|
|
61
|
-
self.config = Config.from_yaml()
|
|
62
|
-
except ValueError:
|
|
63
|
-
self.config = Config.from_env()
|
|
64
|
-
|
|
65
|
-
self._owns_session = True
|
|
66
|
-
self.token = None
|
|
67
|
-
self.token_expiry = None
|
|
68
|
-
self._http_client = self._authenticate_and_initialize_client()
|
|
112
|
+
# --------------------------- auth/session ---------------------------
|
|
69
113
|
|
|
70
114
|
def _authenticate_and_initialize_client(self) -> requests.Session:
|
|
71
115
|
"""Authenticate and initialize the HTTP client with a valid token."""
|
|
116
|
+
auth = self.config.authentication
|
|
117
|
+
auth_type = getattr(auth, "type", "m2m")
|
|
118
|
+
|
|
119
|
+
if auth_type == "m2m":
|
|
120
|
+
return self.__build_m2m_session()
|
|
121
|
+
|
|
122
|
+
if auth_type == "local":
|
|
123
|
+
return self.__build_local_session()
|
|
124
|
+
|
|
125
|
+
raise DatacosmosException(f"Unsupported authentication type: {auth_type}")
|
|
126
|
+
|
|
127
|
+
def _refresh_token_if_needed(self):
|
|
128
|
+
"""Refresh the token if it has expired (only if SDK created it)."""
|
|
129
|
+
if not getattr(self, "_owns_session", False):
|
|
130
|
+
return
|
|
131
|
+
now = datetime.now(timezone.utc)
|
|
132
|
+
# Treat missing token or missing expiry as 'needs refresh'
|
|
133
|
+
if (
|
|
134
|
+
(not self.token)
|
|
135
|
+
or (self.token_expiry is None)
|
|
136
|
+
or (self.token_expiry <= now)
|
|
137
|
+
):
|
|
138
|
+
self._http_client = self._authenticate_and_initialize_client()
|
|
139
|
+
|
|
140
|
+
def __build_m2m_session(self) -> requests.Session:
|
|
141
|
+
"""Client Credentials (M2M) flow using requests-oauthlib."""
|
|
142
|
+
auth = self.config.authentication
|
|
72
143
|
try:
|
|
73
|
-
client = BackendApplicationClient(
|
|
74
|
-
client_id=self.config.authentication.client_id
|
|
75
|
-
)
|
|
144
|
+
client = BackendApplicationClient(client_id=auth.client_id)
|
|
76
145
|
oauth_session = OAuth2Session(client=client)
|
|
77
146
|
|
|
78
147
|
token_response = oauth_session.fetch_token(
|
|
79
|
-
token_url=
|
|
80
|
-
client_id=
|
|
81
|
-
client_secret=
|
|
82
|
-
audience=
|
|
148
|
+
token_url=auth.token_url,
|
|
149
|
+
client_id=auth.client_id,
|
|
150
|
+
client_secret=auth.client_secret,
|
|
151
|
+
audience=auth.audience,
|
|
83
152
|
)
|
|
84
153
|
|
|
85
154
|
self.token = token_response["access_token"]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
155
|
+
expires_at = token_response.get("expires_at")
|
|
156
|
+
if isinstance(expires_at, (int, float)):
|
|
157
|
+
self.token_expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc)
|
|
158
|
+
else:
|
|
159
|
+
self.token_expiry = datetime.now(timezone.utc) + timedelta(
|
|
160
|
+
seconds=int(token_response.get("expires_in", 3600))
|
|
161
|
+
)
|
|
89
162
|
|
|
90
163
|
http_client = requests.Session()
|
|
91
164
|
http_client.headers.update({"Authorization": f"Bearer {self.token}"})
|
|
92
165
|
return http_client
|
|
166
|
+
|
|
93
167
|
except (HTTPError, ConnectionError, Timeout) as e:
|
|
94
|
-
raise DatacosmosException(f"Authentication failed: {
|
|
168
|
+
raise DatacosmosException(f"Authentication failed: {e}") from e
|
|
95
169
|
except RequestException as e:
|
|
96
170
|
raise DatacosmosException(
|
|
97
|
-
f"Unexpected request failure during authentication: {
|
|
171
|
+
f"Unexpected request failure during authentication: {e}"
|
|
98
172
|
) from e
|
|
99
173
|
|
|
100
|
-
def
|
|
101
|
-
"""
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
174
|
+
def __build_local_session(self) -> requests.Session:
|
|
175
|
+
"""Interactive local login via LocalTokenFetcher (cached + refresh)."""
|
|
176
|
+
auth = self.config.authentication
|
|
177
|
+
try:
|
|
178
|
+
from datacosmos.auth.local_token_fetcher import LocalTokenFetcher
|
|
179
|
+
|
|
180
|
+
fetcher = LocalTokenFetcher(
|
|
181
|
+
client_id=auth.client_id,
|
|
182
|
+
authorization_endpoint=auth.authorization_endpoint,
|
|
183
|
+
token_endpoint=auth.token_endpoint,
|
|
184
|
+
redirect_port=int(auth.redirect_port),
|
|
185
|
+
audience=auth.audience,
|
|
186
|
+
scopes=auth.scopes,
|
|
187
|
+
token_file=Path(auth.cache_file).expanduser(),
|
|
188
|
+
)
|
|
189
|
+
tok = fetcher.get_token()
|
|
190
|
+
except Exception as e:
|
|
191
|
+
raise DatacosmosException(f"Local authentication failed: {e}") from e
|
|
192
|
+
|
|
193
|
+
self.token = tok.access_token
|
|
194
|
+
self.token_expiry = datetime.fromtimestamp(tok.expires_at, tz=timezone.utc)
|
|
195
|
+
|
|
196
|
+
http_client = requests.Session()
|
|
197
|
+
http_client.headers.update({"Authorization": f"Bearer {self.token}"})
|
|
198
|
+
|
|
199
|
+
# keep for potential reuse in refresh path (optional)
|
|
200
|
+
self._local_token_fetcher = fetcher
|
|
201
|
+
return http_client
|
|
202
|
+
|
|
203
|
+
# --------------------------- request API ---------------------------
|
|
106
204
|
|
|
107
205
|
def request(
|
|
108
206
|
self, method: str, url: str, *args: Any, **kwargs: Any
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datacosmos
|
|
3
|
-
Version: 0.0.
|
|
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
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
datacosmos/__init__.py,sha256=dVHKpbz5FVtfoJAWHRdsUENG6H-vs4UrkuwnIvOGJr4,66
|
|
2
|
-
datacosmos/datacosmos_client.py,sha256=
|
|
2
|
+
datacosmos/datacosmos_client.py,sha256=qbKAzym-84ihLO9WiUgw15QbAEplxPmLc4BMUBtNoEg,10191
|
|
3
|
+
datacosmos/auth/__init__.py,sha256=ynCThS9QyLKV9miRdnjm8uF_breiGGiCcI0FaOSw_2o,45
|
|
4
|
+
datacosmos/auth/local_token_fetcher.py,sha256=E4MI2lTRHAmxIQA7qY6hmpAUZETTzXNO8MViBaXnxGs,5268
|
|
5
|
+
datacosmos/auth/token.py,sha256=neSV9gnnFa-rxEwMAJlZe_cReV6g4PQf8mq4-1mZzB8,2558
|
|
3
6
|
datacosmos/config/__init__.py,sha256=KCsaTb9-ZgFui1GM8wZFIPLJy0D0O8l8Z1Sv3NRD9UM,140
|
|
4
|
-
datacosmos/config/config.py,sha256=
|
|
7
|
+
datacosmos/config/config.py,sha256=JHWmS6Q_T32iMly7cvJncjPCvtIgamBigKJJcvjGH7g,3465
|
|
8
|
+
datacosmos/config/constants.py,sha256=HztzJ0OCti20uMXPQGvCFf_M4Vz1xRdxD2_2k9mAThs,855
|
|
9
|
+
datacosmos/config/auth/__init__.py,sha256=jsqJQby3tyugcGtY7BoDKvv5qotkAomPc2uOELUlKtE,51
|
|
10
|
+
datacosmos/config/auth/factory.py,sha256=svkL58xfqZ2ubcqQGZq9llb4RH-zkwrMPgBDD1Wk3Js,6213
|
|
11
|
+
datacosmos/config/loaders/yaml_source.py,sha256=GY3RZvIjFJagTPiPmOhmFRa8aD0p0rZtTXgywU28fxo,2281
|
|
5
12
|
datacosmos/config/models/__init__.py,sha256=r3lThPkyKjBjUZXRNscFzOrmn_-m_i9DvG3RePfCFYc,41
|
|
6
13
|
datacosmos/config/models/authentication_config.py,sha256=01Q90-yupbJ5orYDtatZIm9EaL7roQ-oUMoZfFMRzIM,499
|
|
7
|
-
datacosmos/config/models/local_user_account_authentication_config.py,sha256=
|
|
8
|
-
datacosmos/config/models/m2m_authentication_config.py,sha256=
|
|
14
|
+
datacosmos/config/models/local_user_account_authentication_config.py,sha256=SJZRmrOm1nILHN9b-yyMN0vrhhHVYO81W-MUHvfUxJ0,971
|
|
15
|
+
datacosmos/config/models/m2m_authentication_config.py,sha256=4l3Mmgips73rYGX5l7FCoHAWpWSGQYYkzZYvQzbmRz0,782
|
|
9
16
|
datacosmos/config/models/no_authentication_config.py,sha256=x5xikSGPuqQbrf_S2oIWXo5XxAORci2sSE5KyJvZHVw,312
|
|
10
17
|
datacosmos/config/models/url.py,sha256=bBeulXQ2c-tLJyIoo3sTi9SPsZIyIDn_D2zmkCGWp9s,1597
|
|
11
18
|
datacosmos/exceptions/__init__.py,sha256=Crz8W7mOvPUXYcfDVotvjUt_3HKawBpmJA_-uel9UJk,45
|
|
@@ -44,8 +51,8 @@ datacosmos/utils/http_response/check_api_response.py,sha256=dKWW01jn2_lWV0xpOBAB
|
|
|
44
51
|
datacosmos/utils/http_response/models/__init__.py,sha256=Wj8YT6dqw7rAz_rctllxo5Or_vv8DwopvQvBzwCTvpw,45
|
|
45
52
|
datacosmos/utils/http_response/models/datacosmos_error.py,sha256=Uqi2uM98nJPeCbM7zngV6vHSk97jEAb_nkdDEeUjiQM,740
|
|
46
53
|
datacosmos/utils/http_response/models/datacosmos_response.py,sha256=oV4n-sue7K1wwiIQeHpxdNU8vxeqF3okVPE2rydw5W0,336
|
|
47
|
-
datacosmos-0.0.
|
|
48
|
-
datacosmos-0.0.
|
|
49
|
-
datacosmos-0.0.
|
|
50
|
-
datacosmos-0.0.
|
|
51
|
-
datacosmos-0.0.
|
|
54
|
+
datacosmos-0.0.13.dist-info/licenses/LICENSE.md,sha256=vpbRI-UUbZVQfr3VG_CXt9HpRnL1b5kt8uTVbirxeyI,1486
|
|
55
|
+
datacosmos-0.0.13.dist-info/METADATA,sha256=8SZ4fh8VHOt8UXkmbu9WnDUX75CEyMdxm777mMZ_0vo,939
|
|
56
|
+
datacosmos-0.0.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
57
|
+
datacosmos-0.0.13.dist-info/top_level.txt,sha256=ueobs5CNeyDbPMgXPcVV0d0yNdm8CvGtDT3CaksRVtA,11
|
|
58
|
+
datacosmos-0.0.13.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|