datacosmos 0.0.12__py3-none-any.whl → 0.0.14__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 +186 -62
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.14.dist-info}/METADATA +2 -1
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.14.dist-info}/RECORD +16 -9
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.14.dist-info}/WHEEL +0 -0
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.14.dist-info}/licenses/LICENSE.md +0 -0
- {datacosmos-0.0.12.dist-info → datacosmos-0.0.14.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,8 @@
|
|
|
1
1
|
"""Client to interact with the Datacosmos API with authentication and request handling."""
|
|
2
2
|
|
|
3
|
+
import threading
|
|
3
4
|
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
from typing import Any, Optional
|
|
5
7
|
|
|
6
8
|
import requests
|
|
@@ -15,6 +17,8 @@ from datacosmos.exceptions.datacosmos_exception import DatacosmosException
|
|
|
15
17
|
class DatacosmosClient:
|
|
16
18
|
"""Client to interact with the Datacosmos API with authentication and request handling."""
|
|
17
19
|
|
|
20
|
+
TOKEN_EXPIRY_SKEW_SECONDS = 60
|
|
21
|
+
|
|
18
22
|
def __init__(
|
|
19
23
|
self,
|
|
20
24
|
config: Optional[Config | Any] = None,
|
|
@@ -26,109 +30,229 @@ class DatacosmosClient:
|
|
|
26
30
|
config (Optional[Config]): Configuration object (only needed when SDK creates its own session).
|
|
27
31
|
http_session (Optional[requests.Session]): Pre-authenticated session.
|
|
28
32
|
"""
|
|
33
|
+
self.config = self._coerce_config(config)
|
|
34
|
+
self.token: Optional[str] = None
|
|
35
|
+
self.token_expiry: Optional[datetime] = None
|
|
36
|
+
self._refresh_lock = threading.Lock()
|
|
37
|
+
|
|
29
38
|
if http_session is not None:
|
|
30
|
-
self.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
self._init_with_injected_session(http_session)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
self._owns_session = True
|
|
43
|
+
self._http_client = self._authenticate_and_initialize_client()
|
|
44
|
+
|
|
45
|
+
# --------------------------- init helpers ---------------------------
|
|
46
|
+
|
|
47
|
+
def _coerce_config(self, cfg: Optional[Config | Any]) -> Config:
|
|
48
|
+
if cfg is None:
|
|
49
|
+
return Config()
|
|
50
|
+
if isinstance(cfg, Config):
|
|
51
|
+
return cfg
|
|
52
|
+
if isinstance(cfg, dict):
|
|
53
|
+
return Config(**cfg)
|
|
54
|
+
try:
|
|
55
|
+
return Config.model_validate(cfg)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise DatacosmosException(
|
|
58
|
+
"Invalid config provided to DatacosmosClient"
|
|
59
|
+
) from e
|
|
60
|
+
|
|
61
|
+
def _init_with_injected_session(
|
|
62
|
+
self, http_session: requests.Session | OAuth2Session
|
|
63
|
+
) -> None:
|
|
64
|
+
self._http_client = http_session
|
|
65
|
+
self._owns_session = False
|
|
66
|
+
|
|
67
|
+
token_data = self._extract_token_data(http_session)
|
|
68
|
+
self.token = token_data.get("access_token")
|
|
69
|
+
if not self.token:
|
|
70
|
+
raise DatacosmosException(
|
|
71
|
+
"Failed to extract access token from injected session"
|
|
72
|
+
)
|
|
73
|
+
self.token_expiry = self._compute_expiry(
|
|
74
|
+
token_data.get("expires_at"), token_data.get("expires_in")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _extract_token_data(
|
|
78
|
+
self, http_session: requests.Session | OAuth2Session
|
|
79
|
+
) -> dict:
|
|
80
|
+
if isinstance(http_session, OAuth2Session):
|
|
81
|
+
return getattr(http_session, "token", {}) or {}
|
|
82
|
+
if isinstance(http_session, requests.Session):
|
|
83
|
+
auth_header = http_session.headers.get("Authorization", "")
|
|
84
|
+
if not auth_header.startswith("Bearer "):
|
|
51
85
|
raise DatacosmosException(
|
|
52
|
-
"
|
|
86
|
+
"Injected requests.Session must include a 'Bearer' token in its headers"
|
|
53
87
|
)
|
|
88
|
+
return {"access_token": auth_header.split(" ", 1)[1]}
|
|
89
|
+
raise DatacosmosException(f"Unsupported session type: {type(http_session)}")
|
|
54
90
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
91
|
+
def _compute_expiry(
|
|
92
|
+
self,
|
|
93
|
+
expires_at: Optional[datetime | int | float],
|
|
94
|
+
expires_in: Optional[int | float],
|
|
95
|
+
) -> Optional[datetime]:
|
|
96
|
+
if isinstance(expires_at, datetime):
|
|
97
|
+
return expires_at
|
|
98
|
+
if isinstance(expires_at, (int, float)):
|
|
99
|
+
return datetime.fromtimestamp(expires_at, tz=timezone.utc)
|
|
100
|
+
if expires_in is not None:
|
|
101
|
+
try:
|
|
102
|
+
return datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
# Unknown/invalid expiry -> mark as unknown so refresh logic kicks in
|
|
105
|
+
return None
|
|
106
|
+
return None
|
|
64
107
|
|
|
65
|
-
|
|
66
|
-
self.token = None
|
|
67
|
-
self.token_expiry = None
|
|
68
|
-
self._http_client = self._authenticate_and_initialize_client()
|
|
108
|
+
# --------------------------- auth/session ---------------------------
|
|
69
109
|
|
|
70
110
|
def _authenticate_and_initialize_client(self) -> requests.Session:
|
|
71
|
-
|
|
111
|
+
auth = self.config.authentication
|
|
112
|
+
auth_type = getattr(auth, "type", "m2m")
|
|
113
|
+
if auth_type == "m2m":
|
|
114
|
+
return self.__build_m2m_session()
|
|
115
|
+
if auth_type == "local":
|
|
116
|
+
return self.__build_local_session()
|
|
117
|
+
raise DatacosmosException(f"Unsupported authentication type: {auth_type}")
|
|
118
|
+
|
|
119
|
+
def __build_m2m_session(self) -> requests.Session:
|
|
120
|
+
"""Client Credentials (M2M) flow using requests-oauthlib."""
|
|
121
|
+
auth = self.config.authentication
|
|
72
122
|
try:
|
|
73
|
-
client = BackendApplicationClient(
|
|
74
|
-
client_id=self.config.authentication.client_id
|
|
75
|
-
)
|
|
123
|
+
client = BackendApplicationClient(client_id=auth.client_id)
|
|
76
124
|
oauth_session = OAuth2Session(client=client)
|
|
77
|
-
|
|
78
125
|
token_response = oauth_session.fetch_token(
|
|
79
|
-
token_url=
|
|
80
|
-
client_id=
|
|
81
|
-
client_secret=
|
|
82
|
-
audience=
|
|
126
|
+
token_url=auth.token_url,
|
|
127
|
+
client_id=auth.client_id,
|
|
128
|
+
client_secret=auth.client_secret,
|
|
129
|
+
audience=auth.audience,
|
|
83
130
|
)
|
|
84
|
-
|
|
85
131
|
self.token = token_response["access_token"]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
expires_at = token_response.get("expires_at")
|
|
133
|
+
if isinstance(expires_at, (int, float)):
|
|
134
|
+
self.token_expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc)
|
|
135
|
+
else:
|
|
136
|
+
self.token_expiry = datetime.now(timezone.utc) + timedelta(
|
|
137
|
+
seconds=int(token_response.get("expires_in", 3600))
|
|
138
|
+
)
|
|
90
139
|
http_client = requests.Session()
|
|
91
140
|
http_client.headers.update({"Authorization": f"Bearer {self.token}"})
|
|
92
141
|
return http_client
|
|
93
142
|
except (HTTPError, ConnectionError, Timeout) as e:
|
|
94
|
-
raise DatacosmosException(f"Authentication failed: {
|
|
143
|
+
raise DatacosmosException(f"Authentication failed: {e}") from e
|
|
95
144
|
except RequestException as e:
|
|
96
145
|
raise DatacosmosException(
|
|
97
|
-
f"Unexpected request failure during authentication: {
|
|
146
|
+
f"Unexpected request failure during authentication: {e}"
|
|
98
147
|
) from e
|
|
99
148
|
|
|
100
|
-
def
|
|
101
|
-
"""
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
149
|
+
def __build_local_session(self) -> requests.Session:
|
|
150
|
+
"""Interactive local login via LocalTokenFetcher (cached + refresh)."""
|
|
151
|
+
auth = self.config.authentication
|
|
152
|
+
try:
|
|
153
|
+
from datacosmos.auth.local_token_fetcher import LocalTokenFetcher
|
|
154
|
+
|
|
155
|
+
fetcher = LocalTokenFetcher(
|
|
156
|
+
client_id=auth.client_id,
|
|
157
|
+
authorization_endpoint=auth.authorization_endpoint,
|
|
158
|
+
token_endpoint=auth.token_endpoint,
|
|
159
|
+
redirect_port=int(auth.redirect_port),
|
|
160
|
+
audience=auth.audience,
|
|
161
|
+
scopes=auth.scopes,
|
|
162
|
+
token_file=Path(auth.cache_file).expanduser(),
|
|
163
|
+
)
|
|
164
|
+
tok = fetcher.get_token()
|
|
165
|
+
except Exception as e:
|
|
166
|
+
raise DatacosmosException(f"Local authentication failed: {e}") from e
|
|
167
|
+
|
|
168
|
+
self.token = tok.access_token
|
|
169
|
+
self.token_expiry = datetime.fromtimestamp(tok.expires_at, tz=timezone.utc)
|
|
170
|
+
|
|
171
|
+
http_client = requests.Session()
|
|
172
|
+
http_client.headers.update({"Authorization": f"Bearer {self.token}"})
|
|
173
|
+
self._local_token_fetcher = fetcher
|
|
174
|
+
return http_client
|
|
175
|
+
|
|
176
|
+
# --------------------------- refresh logic ---------------------------
|
|
177
|
+
|
|
178
|
+
def _needs_refresh(self) -> bool:
|
|
179
|
+
if not getattr(self, "_owns_session", False):
|
|
180
|
+
return False
|
|
181
|
+
if not self.token or self.token_expiry is None:
|
|
182
|
+
return True
|
|
183
|
+
return (self.token_expiry - datetime.now(timezone.utc)) <= timedelta(
|
|
184
|
+
seconds=self.TOKEN_EXPIRY_SKEW_SECONDS
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _refresh_now(self) -> None:
|
|
188
|
+
"""Force refresh.
|
|
189
|
+
|
|
190
|
+
In case of local auth it uses LocalTokenFetcher (non-interactive refresh/cached token).
|
|
191
|
+
In case of m2m auth it re-runs client-credentials flow.
|
|
192
|
+
"""
|
|
193
|
+
with self._refresh_lock:
|
|
194
|
+
if not self._needs_refresh():
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
auth_type = getattr(self.config.authentication, "type", "m2m")
|
|
198
|
+
if auth_type == "local" and hasattr(self, "_local_token_fetcher"):
|
|
199
|
+
tok = self._local_token_fetcher.get_token()
|
|
200
|
+
self.token = tok.access_token
|
|
201
|
+
self.token_expiry = datetime.fromtimestamp(
|
|
202
|
+
tok.expires_at, tz=timezone.utc
|
|
203
|
+
)
|
|
204
|
+
self._http_client.headers.update(
|
|
205
|
+
{"Authorization": f"Bearer {self.token}"}
|
|
206
|
+
)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# default/m2m:
|
|
210
|
+
self._http_client = self.__build_m2m_session()
|
|
211
|
+
|
|
212
|
+
def _refresh_token_if_needed(self) -> None:
|
|
213
|
+
if self._needs_refresh():
|
|
214
|
+
self._refresh_now()
|
|
215
|
+
|
|
216
|
+
# --------------------------- request API ---------------------------
|
|
106
217
|
|
|
107
218
|
def request(
|
|
108
219
|
self, method: str, url: str, *args: Any, **kwargs: Any
|
|
109
220
|
) -> requests.Response:
|
|
110
|
-
"""Send an HTTP request using the authenticated session."""
|
|
221
|
+
"""Send an HTTP request using the authenticated session (with auto-refresh)."""
|
|
111
222
|
self._refresh_token_if_needed()
|
|
112
223
|
try:
|
|
113
224
|
response = self._http_client.request(method, url, *args, **kwargs)
|
|
114
225
|
response.raise_for_status()
|
|
115
226
|
return response
|
|
116
227
|
except HTTPError as e:
|
|
228
|
+
status = getattr(e.response, "status_code", None)
|
|
229
|
+
if status in (401, 403) and getattr(self, "_owns_session", False):
|
|
230
|
+
# token likely expired/invalid — refresh once and retry
|
|
231
|
+
self._refresh_now()
|
|
232
|
+
retry_response = self._http_client.request(method, url, *args, **kwargs)
|
|
233
|
+
try:
|
|
234
|
+
retry_response.raise_for_status()
|
|
235
|
+
return retry_response
|
|
236
|
+
except HTTPError as e:
|
|
237
|
+
raise DatacosmosException(
|
|
238
|
+
f"HTTP error during {method.upper()} request to {url} after refresh",
|
|
239
|
+
response=e.response,
|
|
240
|
+
) from e
|
|
117
241
|
raise DatacosmosException(
|
|
118
242
|
f"HTTP error during {method.upper()} request to {url}",
|
|
119
|
-
response=e
|
|
243
|
+
response=getattr(e, "response", None),
|
|
120
244
|
) from e
|
|
121
245
|
except ConnectionError as e:
|
|
122
246
|
raise DatacosmosException(
|
|
123
|
-
f"Connection error during {method.upper()} request to {url}: {
|
|
247
|
+
f"Connection error during {method.upper()} request to {url}: {e}"
|
|
124
248
|
) from e
|
|
125
249
|
except Timeout as e:
|
|
126
250
|
raise DatacosmosException(
|
|
127
|
-
f"Request timeout during {method.upper()} request to {url}: {
|
|
251
|
+
f"Request timeout during {method.upper()} request to {url}: {e}"
|
|
128
252
|
) from e
|
|
129
253
|
except RequestException as e:
|
|
130
254
|
raise DatacosmosException(
|
|
131
|
-
f"Unexpected request failure during {method.upper()} request to {url}: {
|
|
255
|
+
f"Unexpected request failure during {method.upper()} request to {url}: {e}"
|
|
132
256
|
) from e
|
|
133
257
|
|
|
134
258
|
def get(self, url: str, *args: Any, **kwargs: Any) -> requests.Response:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datacosmos
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.14
|
|
4
4
|
Summary: A library for interacting with DataCosmos from Python code
|
|
5
5
|
Author-email: Open Cosmos <support@open-cosmos.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -20,6 +20,7 @@ Requires-Dist: black==22.3.0; extra == "dev"
|
|
|
20
20
|
Requires-Dist: ruff==0.9.5; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest==7.2.0; extra == "dev"
|
|
22
22
|
Requires-Dist: bandit[toml]==1.7.4; extra == "dev"
|
|
23
|
+
Requires-Dist: pbr>=6.0.0; extra == "dev"
|
|
23
24
|
Requires-Dist: isort==5.11.4; extra == "dev"
|
|
24
25
|
Requires-Dist: pydocstyle==6.1.1; extra == "dev"
|
|
25
26
|
Dynamic: license-file
|
|
@@ -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=_4a0dE5aQ54bVMoZuPZq4cFU1OmBd4XOyDI70XKnEx8,11572
|
|
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.14.dist-info/licenses/LICENSE.md,sha256=vpbRI-UUbZVQfr3VG_CXt9HpRnL1b5kt8uTVbirxeyI,1486
|
|
55
|
+
datacosmos-0.0.14.dist-info/METADATA,sha256=ZVSLf-KFLC2HWqoT5U4TY-f5jd057dilD29ZI86hNQk,939
|
|
56
|
+
datacosmos-0.0.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
57
|
+
datacosmos-0.0.14.dist-info/top_level.txt,sha256=ueobs5CNeyDbPMgXPcVV0d0yNdm8CvGtDT3CaksRVtA,11
|
|
58
|
+
datacosmos-0.0.14.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|