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.

@@ -0,0 +1 @@
1
+ """Handles authentication related things."""
@@ -0,0 +1,156 @@
1
+ """Opens a browser for the user to log in (Authorization Code), caches token to a file, and refreshes when expired."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.server
6
+ import json
7
+ import socketserver
8
+ import time
9
+ import urllib.parse
10
+ import webbrowser
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import requests
17
+
18
+ from datacosmos.auth.token import Token
19
+
20
+
21
+ @dataclass
22
+ class LocalTokenFetcher:
23
+ """Opens a browser for the user to log in (Authorization Code), caches token to a file, and refreshes when expired."""
24
+
25
+ client_id: str
26
+ authorization_endpoint: str
27
+ token_endpoint: str
28
+ redirect_port: int
29
+ audience: str
30
+ scopes: str
31
+ token_file: Path
32
+
33
+ def get_token(self) -> Token:
34
+ """Return a valid token from cache, or refresh / interact as needed."""
35
+ tok = self.__load()
36
+ if not tok:
37
+ return self.__interactive_login()
38
+
39
+ if tok.is_expired():
40
+ # Try to refresh; if that fails for any reason, fall back to interactive login.
41
+ try:
42
+ return self.__refresh(tok)
43
+ except (requests.HTTPError, RuntimeError):
44
+ return self.__interactive_login()
45
+
46
+ return tok
47
+
48
+ def __save(self, token: Token) -> None:
49
+ self.token_file.parent.mkdir(parents=True, exist_ok=True)
50
+ with open(self.token_file, "w") as f:
51
+ json.dump(
52
+ {
53
+ "access_token": token.access_token,
54
+ "refresh_token": token.refresh_token,
55
+ "expires_at": token.expires_at,
56
+ },
57
+ f,
58
+ )
59
+
60
+ def __load(self) -> Optional[Token]:
61
+ if not self.token_file.exists():
62
+ return None
63
+ with open(self.token_file, "r") as f:
64
+ data = json.load(f)
65
+ return Token(
66
+ access_token=data["access_token"],
67
+ refresh_token=data.get("refresh_token"),
68
+ expires_at=int(data["expires_at"]),
69
+ )
70
+
71
+ def __exchange_code(self, code: str) -> Token:
72
+ data = {
73
+ "grant_type": "authorization_code",
74
+ "code": code,
75
+ "redirect_uri": f"http://localhost:{self.redirect_port}/oauth/callback",
76
+ "client_id": self.client_id,
77
+ "audience": self.audience,
78
+ }
79
+ resp = requests.post(self.token_endpoint, data=data, timeout=30)
80
+ resp.raise_for_status()
81
+ return Token.from_json_response(resp.json())
82
+
83
+ def __refresh(self, token: Token) -> Token:
84
+ """Refresh the token, persist it on success, and return it.
85
+
86
+ Raises:
87
+ RuntimeError: if no refresh_token is available.
88
+ requests.HTTPError: if the token endpoint returns an error.
89
+ """
90
+ if not token.refresh_token:
91
+ raise RuntimeError("No refresh_token available for local auth refresh")
92
+
93
+ data = {
94
+ "grant_type": "refresh_token",
95
+ "refresh_token": token.refresh_token,
96
+ "client_id": self.client_id,
97
+ "audience": self.audience,
98
+ }
99
+ resp = requests.post(self.token_endpoint, data=data, timeout=30)
100
+ resp.raise_for_status() # will raise requests.HTTPError on non-2xx
101
+
102
+ payload = resp.json()
103
+ refreshed = Token(
104
+ access_token=payload["access_token"],
105
+ refresh_token=token.refresh_token,
106
+ expires_at=time.time() + int(payload.get("expires_in", 3600)),
107
+ )
108
+ self.__save(refreshed)
109
+ return refreshed
110
+
111
+ def __interactive_login(self) -> Token:
112
+ params = {
113
+ "client_id": self.client_id,
114
+ "response_type": "code",
115
+ "redirect_uri": f"http://localhost:{self.redirect_port}/oauth/callback",
116
+ "audience": self.audience,
117
+ "scope": self.scopes,
118
+ }
119
+ url = f"{self.authorization_endpoint}?{urllib.parse.urlencode(params)}"
120
+
121
+ with suppress(Exception):
122
+ webbrowser.open(url, new=1, autoraise=True)
123
+
124
+ class Handler(http.server.BaseHTTPRequestHandler):
125
+ code: Optional[str] = None
126
+
127
+ def do_GET(self): # noqa: N802
128
+ qs = urllib.parse.urlparse(self.path).query
129
+ data = urllib.parse.parse_qs(qs)
130
+ if "code" in data:
131
+ Handler.code = data["code"][0]
132
+ self.send_response(200)
133
+ self.end_headers()
134
+ self.wfile.write(b"Login complete. You can close this window.")
135
+ else:
136
+ self.send_response(400)
137
+ self.end_headers()
138
+ self.wfile.write(b"No authorization code found.")
139
+
140
+ def log_message(self, *_args, **_kwargs) -> None:
141
+ return
142
+
143
+ with socketserver.TCPServer(
144
+ ("localhost", int(self.redirect_port)), Handler
145
+ ) as httpd:
146
+ httpd.timeout = 300 # 5 minutes
147
+ httpd.handle_request()
148
+
149
+ if not Handler.code:
150
+ raise RuntimeError(
151
+ f"Login timed out. If your browser did not open, visit:\n{url}"
152
+ )
153
+
154
+ token = self.__exchange_code(Handler.code)
155
+ self.__save(token)
156
+ return token
@@ -0,0 +1,82 @@
1
+ """Authentication Token class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+ DEFAULT_TOKEN_TTL_FALLBACK = 300 # seconds
12
+
13
+
14
+ @dataclass
15
+ class Token:
16
+ """Authentication token class."""
17
+
18
+ access_token: str
19
+ refresh_token: Optional[str]
20
+ expires_at: int # unix epoch seconds
21
+
22
+ @classmethod
23
+ def from_json_response(cls, data: dict) -> Token:
24
+ """Build a Token from an OAuth2 response.
25
+
26
+ Prefer `expires_at` (absolute epoch seconds), then `expires_in`
27
+ (relative per RFC 6749). If both are missing, try to derive `exp`
28
+ from a JWT access token; otherwise fall back to a short TTL.
29
+ """
30
+ exp: Optional[int] = None
31
+
32
+ if data.get("expires_at") is not None:
33
+ try:
34
+ exp = int(float(data["expires_at"]))
35
+ except (TypeError, ValueError):
36
+ exp = None
37
+
38
+ if exp is None and data.get("expires_in") is not None:
39
+ try:
40
+ exp = int(time.time()) + int(data["expires_in"])
41
+ except (TypeError, ValueError, OverflowError):
42
+ exp = None
43
+
44
+ if exp is None:
45
+ exp = cls.__jwt_exp(data.get("access_token", ""))
46
+
47
+ if exp is None:
48
+ exp = int(time.time()) + DEFAULT_TOKEN_TTL_FALLBACK
49
+
50
+ return cls(
51
+ access_token=data["access_token"],
52
+ refresh_token=data.get("refresh_token"),
53
+ expires_at=exp,
54
+ )
55
+
56
+ def is_expired(self, skew_seconds: int = 30) -> bool:
57
+ """Treat the token as expired slightly early to account for clock skew."""
58
+ return time.time() >= (self.expires_at - skew_seconds)
59
+
60
+ @staticmethod
61
+ def __jwt_exp(access_token: str) -> Optional[int]:
62
+ """Best-effort extract of `exp` (epoch seconds) from a JWT access token.
63
+
64
+ We do NOT validate the signature here; this is only used as a heuristic
65
+ when the IdP omits both `expires_at` and `expires_in`.
66
+ """
67
+ if not access_token:
68
+ return None
69
+ try:
70
+ parts = access_token.split(".")
71
+ if len(parts) != 3:
72
+ return None
73
+ payload_b64 = parts[1]
74
+ padding = "=" * (-len(payload_b64) % 4)
75
+ payload = json.loads(
76
+ base64.urlsafe_b64decode(payload_b64 + padding).decode()
77
+ )
78
+ if "exp" in payload:
79
+ return int(payload["exp"])
80
+ except Exception:
81
+ return None
82
+ return None
@@ -0,0 +1 @@
1
+ """Configuration authentication related things."""
@@ -0,0 +1,157 @@
1
+ """Config authentication factory.
2
+
3
+ This module normalizes the `authentication` config into a concrete model:
4
+ - `parse_auth_config` converts raw dicts (e.g., from YAML/env) into a model instance.
5
+ - `apply_auth_defaults` fills sensible defaults per auth type without inventing secrets.
6
+ - `check_required_auth_fields` enforces the minimum required inputs.
7
+ - `normalize_authentication` runs the whole pipeline.
8
+
9
+ Design notes:
10
+ - Auth models accept partial data (fields are Optional with None defaults).
11
+ - We DO NOT pass `None` explicitly when constructing models here.
12
+ - Required-ness is enforced centrally by `check_required_auth_fields`, not by model init.
13
+ """
14
+
15
+ from typing import Optional, Union, cast
16
+
17
+ from datacosmos.config.constants import (
18
+ DEFAULT_AUTH_AUDIENCE,
19
+ DEFAULT_AUTH_TOKEN_URL,
20
+ DEFAULT_AUTH_TYPE,
21
+ DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT,
22
+ DEFAULT_LOCAL_CACHE_FILE,
23
+ DEFAULT_LOCAL_REDIRECT_PORT,
24
+ DEFAULT_LOCAL_SCOPES,
25
+ DEFAULT_LOCAL_TOKEN_ENDPOINT,
26
+ )
27
+ from datacosmos.config.models.local_user_account_authentication_config import (
28
+ LocalUserAccountAuthenticationConfig,
29
+ )
30
+ from datacosmos.config.models.m2m_authentication_config import M2MAuthenticationConfig
31
+
32
+ AuthModel = Union[M2MAuthenticationConfig, LocalUserAccountAuthenticationConfig]
33
+
34
+
35
+ def parse_auth_config(raw: dict | AuthModel | None) -> Optional[AuthModel]:
36
+ """Turn a raw dict (e.g., from YAML) into a concrete auth model.
37
+
38
+ - If `raw` is already an auth model (M2M or local), return it unchanged.
39
+ - If `raw` is a dict, choose/validate the type using `raw['type']`
40
+ (or DEFAULT_AUTH_TYPE), then construct the corresponding model.
41
+ For missing fields we *may* apply non-secret defaults (endpoints, etc.).
42
+ """
43
+ if raw is None or isinstance(
44
+ raw, (M2MAuthenticationConfig, LocalUserAccountAuthenticationConfig)
45
+ ):
46
+ return cast(Optional[AuthModel], raw)
47
+
48
+ auth_type = _normalize_auth_type(raw.get("type") or DEFAULT_AUTH_TYPE)
49
+
50
+ if auth_type == "local":
51
+ return LocalUserAccountAuthenticationConfig(
52
+ type="local",
53
+ client_id=raw.get("client_id"),
54
+ authorization_endpoint=raw.get(
55
+ "authorization_endpoint", DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT
56
+ ),
57
+ token_endpoint=raw.get("token_endpoint", DEFAULT_LOCAL_TOKEN_ENDPOINT),
58
+ redirect_port=raw.get("redirect_port", DEFAULT_LOCAL_REDIRECT_PORT),
59
+ scopes=raw.get("scopes", DEFAULT_LOCAL_SCOPES),
60
+ audience=raw.get("audience", DEFAULT_AUTH_AUDIENCE),
61
+ cache_file=raw.get("cache_file", DEFAULT_LOCAL_CACHE_FILE),
62
+ )
63
+
64
+ return M2MAuthenticationConfig(
65
+ type="m2m",
66
+ token_url=raw.get("token_url", DEFAULT_AUTH_TOKEN_URL),
67
+ audience=raw.get("audience", DEFAULT_AUTH_AUDIENCE),
68
+ client_id=raw.get("client_id"),
69
+ client_secret=raw.get("client_secret"),
70
+ )
71
+
72
+
73
+ def apply_auth_defaults(auth: AuthModel | None) -> AuthModel:
74
+ """Fill in any missing defaults by type (non-secret values only).
75
+
76
+ If `auth` is None, construct a default "shell" based on DEFAULT_AUTH_TYPE,
77
+ without passing None for unknown credentials.
78
+ """
79
+ if auth is None:
80
+ default_type = _normalize_auth_type(DEFAULT_AUTH_TYPE)
81
+ if default_type == "local":
82
+ auth = LocalUserAccountAuthenticationConfig(
83
+ type="local",
84
+ authorization_endpoint=DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT,
85
+ token_endpoint=DEFAULT_LOCAL_TOKEN_ENDPOINT,
86
+ redirect_port=DEFAULT_LOCAL_REDIRECT_PORT,
87
+ scopes=DEFAULT_LOCAL_SCOPES,
88
+ audience=DEFAULT_AUTH_AUDIENCE,
89
+ cache_file=DEFAULT_LOCAL_CACHE_FILE,
90
+ )
91
+ else: # "m2m"
92
+ auth = M2MAuthenticationConfig(
93
+ type="m2m",
94
+ token_url=DEFAULT_AUTH_TOKEN_URL,
95
+ audience=DEFAULT_AUTH_AUDIENCE,
96
+ )
97
+
98
+ if isinstance(auth, M2MAuthenticationConfig):
99
+ auth.type = auth.type or "m2m"
100
+ auth.token_url = auth.token_url or DEFAULT_AUTH_TOKEN_URL
101
+ auth.audience = auth.audience or DEFAULT_AUTH_AUDIENCE
102
+ return auth
103
+
104
+ # Local defaults (Pydantic already coerces types; only set when missing)
105
+ auth.type = auth.type or "local"
106
+ auth.authorization_endpoint = (
107
+ auth.authorization_endpoint or DEFAULT_LOCAL_AUTHORIZATION_ENDPOINT
108
+ )
109
+ auth.token_endpoint = auth.token_endpoint or DEFAULT_LOCAL_TOKEN_ENDPOINT
110
+ if auth.redirect_port is None:
111
+ auth.redirect_port = DEFAULT_LOCAL_REDIRECT_PORT
112
+ auth.scopes = auth.scopes or DEFAULT_LOCAL_SCOPES
113
+ auth.audience = auth.audience or DEFAULT_AUTH_AUDIENCE
114
+ auth.cache_file = auth.cache_file or DEFAULT_LOCAL_CACHE_FILE
115
+ return auth
116
+
117
+
118
+ def check_required_auth_fields(auth: AuthModel) -> None:
119
+ """Enforce required fields per auth type.
120
+
121
+ - m2m requires client_id and client_secret.
122
+ - local requires client_id.
123
+ """
124
+ if isinstance(auth, M2MAuthenticationConfig):
125
+ missing = [f for f in ("client_id", "client_secret") if not getattr(auth, f)]
126
+ if missing:
127
+ raise ValueError(
128
+ f"Missing required authentication fields for m2m: {', '.join(missing)}"
129
+ )
130
+ return
131
+
132
+ if isinstance(auth, LocalUserAccountAuthenticationConfig):
133
+ if not auth.client_id:
134
+ raise ValueError(
135
+ "Missing required authentication field for local: client_id"
136
+ )
137
+ return
138
+
139
+ raise ValueError(f"Unsupported authentication model: {type(auth).__name__}")
140
+
141
+
142
+ def normalize_authentication(raw: dict | AuthModel | None) -> AuthModel:
143
+ """End-to-end auth normalization: parse -> apply defaults -> required-field checks."""
144
+ model = parse_auth_config(raw)
145
+ model = apply_auth_defaults(model)
146
+ check_required_auth_fields(model)
147
+ return model
148
+
149
+
150
+ def _normalize_auth_type(value: str) -> str:
151
+ """Return a normalized auth type or raise for unsupported values."""
152
+ v = (value or "").strip().lower()
153
+ if v in {"m2m", "local"}:
154
+ return v
155
+ raise ValueError(
156
+ f"Unsupported authentication type: {value!r}. Expected 'm2m' or 'local'."
157
+ )
@@ -5,15 +5,19 @@ It loads default values, allows overrides via YAML configuration files,
5
5
  and supports environment variable-based overrides.
6
6
  """
7
7
 
8
- import os
9
- from typing import ClassVar, Optional
8
+ from typing import Optional
10
9
 
11
- import yaml
12
10
  from pydantic import field_validator
13
11
  from pydantic_settings import BaseSettings, SettingsConfigDict
14
12
 
13
+ from datacosmos.config.auth.factory import normalize_authentication, parse_auth_config
14
+ from datacosmos.config.constants import (
15
+ DEFAULT_CONFIG_YAML,
16
+ DEFAULT_STAC,
17
+ DEFAULT_STORAGE,
18
+ )
19
+ from datacosmos.config.loaders.yaml_source import yaml_settings_source
15
20
  from datacosmos.config.models.authentication_config import AuthenticationConfig
16
- from datacosmos.config.models.m2m_authentication_config import M2MAuthenticationConfig
17
21
  from datacosmos.config.models.url import URL
18
22
 
19
23
 
@@ -27,196 +31,71 @@ class Config(BaseSettings):
27
31
  )
28
32
 
29
33
  authentication: Optional[AuthenticationConfig] = None
30
- stac: Optional[URL] = None
31
- datacosmos_cloud_storage: Optional[URL] = None
32
- datacosmos_public_cloud_storage: Optional[URL] = None
33
-
34
- DEFAULT_AUTH_TYPE: ClassVar[str] = "m2m"
35
- DEFAULT_AUTH_TOKEN_URL: ClassVar[str] = "https://login.open-cosmos.com/oauth/token"
36
- DEFAULT_AUTH_AUDIENCE: ClassVar[str] = "https://beeapp.open-cosmos.com"
37
-
38
- @classmethod
39
- def from_yaml(cls, file_path: str = "config/config.yaml") -> "Config":
40
- """Load configuration from a YAML file and override defaults.
41
-
42
- Args:
43
- file_path (str): The path to the YAML configuration file.
44
-
45
- Returns:
46
- Config: An instance of the Config class with loaded settings.
47
- """
48
- config_data: dict = {}
49
- if os.path.exists(file_path):
50
- with open(file_path, "r") as f:
51
- yaml_data = yaml.safe_load(f) or {}
52
- # Remove empty values from YAML to avoid overwriting with `None`
53
- config_data = {
54
- key: value
55
- for key, value in yaml_data.items()
56
- if value not in [None, ""]
57
- }
58
-
59
- return cls(**config_data)
34
+ stac: URL | None = None
35
+ datacosmos_cloud_storage: URL | None = None
36
+ datacosmos_public_cloud_storage: URL | None = None
60
37
 
61
38
  @classmethod
62
- def from_env(cls) -> "Config":
63
- """Load configuration from environment variables.
64
-
65
- Returns:
66
- Config: An instance of the Config class with settings loaded from environment variables.
67
- """
68
- authentication_config = M2MAuthenticationConfig(
69
- type=os.getenv("OC_AUTH_TYPE", cls.DEFAULT_AUTH_TYPE),
70
- client_id=os.getenv("OC_AUTH_CLIENT_ID"),
71
- client_secret=os.getenv("OC_AUTH_CLIENT_SECRET"),
72
- token_url=os.getenv("OC_AUTH_TOKEN_URL", cls.DEFAULT_AUTH_TOKEN_URL),
73
- audience=os.getenv("OC_AUTH_AUDIENCE", cls.DEFAULT_AUTH_AUDIENCE),
74
- )
75
-
76
- stac_config = URL(
77
- protocol=os.getenv("OC_STAC_PROTOCOL", "https"),
78
- host=os.getenv("OC_STAC_HOST", "app.open-cosmos.com"),
79
- port=int(os.getenv("OC_STAC_PORT", "443")),
80
- path=os.getenv("OC_STAC_PATH", "/api/data/v0/stac"),
39
+ def settings_customise_sources(cls, *args, **kwargs):
40
+ """Sets customised sources."""
41
+ init_settings = kwargs.get("init_settings") or (
42
+ args[1] if len(args) > 1 else None
81
43
  )
82
-
83
- datacosmos_cloud_storage_config = URL(
84
- protocol=os.getenv("DC_CLOUD_STORAGE_PROTOCOL", "https"),
85
- host=os.getenv("DC_CLOUD_STORAGE_HOST", "app.open-cosmos.com"),
86
- port=int(os.getenv("DC_CLOUD_STORAGE_PORT", "443")),
87
- path=os.getenv("DC_CLOUD_STORAGE_PATH", "/api/data/v0/storage"),
44
+ env_settings = kwargs.get("env_settings") or (
45
+ args[2] if len(args) > 2 else None
88
46
  )
89
-
90
- datacosmos_public_cloud_storage_config = URL(
91
- protocol=os.getenv("DC_PUBLIC_CLOUD_STORAGE_PROTOCOL", "https"),
92
- host=os.getenv("DC_PUBLIC_CLOUD_STORAGE_HOST", "app.open-cosmos.com"),
93
- port=int(os.getenv("DC_PUBLIC_CLOUD_STORAGE_PORT", "443")),
94
- path=os.getenv("DC_PUBLIC_CLOUD_STORAGE_PATH", "/api/data/v0/storage"),
47
+ dotenv_settings = kwargs.get("dotenv_settings") or (
48
+ args[3] if len(args) > 3 else None
95
49
  )
96
-
97
- return cls(
98
- authentication=authentication_config,
99
- stac=stac_config,
100
- datacosmos_cloud_storage=datacosmos_cloud_storage_config,
101
- datacosmos_public_cloud_storage=datacosmos_public_cloud_storage_config,
50
+ file_secret_settings = kwargs.get("file_secret_settings") or (
51
+ args[4] if len(args) > 4 else None
102
52
  )
103
53
 
104
- @field_validator("authentication", mode="after")
105
- @classmethod
106
- def validate_authentication(
107
- cls, auth: Optional[M2MAuthenticationConfig]
108
- ) -> M2MAuthenticationConfig:
109
- """Ensure authentication is provided and apply defaults.
110
-
111
- Args:
112
- auth (Optional[M2MAuthenticationConfig]): The authentication config.
113
-
114
- Returns:
115
- M2MAuthenticationConfig: The validated authentication configuration.
116
-
117
- Raises:
118
- ValueError: If authentication is missing or required fields are not set.
119
- """
120
- if auth is None:
121
- auth = cls.apply_auth_defaults(M2MAuthenticationConfig())
122
- else:
123
- auth = cls.apply_auth_defaults(auth)
124
-
125
- cls.check_required_auth_fields(auth)
126
- return auth
127
-
128
- @staticmethod
129
- def apply_auth_defaults(auth: M2MAuthenticationConfig) -> M2MAuthenticationConfig:
130
- """Apply default authentication values if they are missing."""
131
- auth.type = auth.type or Config.DEFAULT_AUTH_TYPE
132
- auth.token_url = auth.token_url or Config.DEFAULT_AUTH_TOKEN_URL
133
- auth.audience = auth.audience or Config.DEFAULT_AUTH_AUDIENCE
134
- return auth
54
+ sources = [
55
+ init_settings,
56
+ yaml_settings_source(DEFAULT_CONFIG_YAML),
57
+ env_settings,
58
+ dotenv_settings,
59
+ file_secret_settings,
60
+ ]
61
+ return tuple(s for s in sources if s is not None)
135
62
 
63
+ @field_validator("authentication", mode="before")
136
64
  @classmethod
137
- def parse_auth_config(cls, auth_data: dict) -> M2MAuthenticationConfig:
138
- """Parse authentication config from a dictionary."""
139
- return M2MAuthenticationConfig(
140
- type=auth_data.get("type", cls.DEFAULT_AUTH_TYPE),
141
- token_url=auth_data.get("token_url", cls.DEFAULT_AUTH_TOKEN_URL),
142
- audience=auth_data.get("audience", cls.DEFAULT_AUTH_AUDIENCE),
143
- client_id=auth_data.get("client_id"),
144
- client_secret=auth_data.get("client_secret"),
65
+ def _parse_authentication(cls, raw):
66
+ if raw is None:
67
+ return None
68
+ from datacosmos.config.models.local_user_account_authentication_config import (
69
+ LocalUserAccountAuthenticationConfig,
70
+ )
71
+ from datacosmos.config.models.m2m_authentication_config import (
72
+ M2MAuthenticationConfig,
145
73
  )
146
74
 
147
- @staticmethod
148
- def check_required_auth_fields(auth: M2MAuthenticationConfig):
149
- """Ensure required fields (client_id, client_secret) are provided."""
150
- missing_fields = [
151
- field
152
- for field in ("client_id", "client_secret")
153
- if not getattr(auth, field)
154
- ]
155
- if missing_fields:
156
- raise ValueError(
157
- f"Missing required authentication fields: {', '.join(missing_fields)}"
158
- )
75
+ if isinstance(
76
+ raw, (M2MAuthenticationConfig, LocalUserAccountAuthenticationConfig)
77
+ ):
78
+ return raw
79
+ if isinstance(raw, dict):
80
+ return parse_auth_config(raw)
81
+ return raw
82
+
83
+ @field_validator("authentication", mode="after")
84
+ @classmethod
85
+ def _validate_authentication(cls, auth: Optional[AuthenticationConfig]):
86
+ return normalize_authentication(auth)
159
87
 
160
88
  @field_validator("stac", mode="before")
161
89
  @classmethod
162
- def validate_stac(cls, stac_config: Optional[URL]) -> URL:
163
- """Ensure STAC configuration has a default if not explicitly set.
164
-
165
- Args:
166
- stac_config (Optional[URL]): The STAC config to validate.
167
-
168
- Returns:
169
- URL: The validated STAC configuration.
170
- """
171
- if stac_config is None:
172
- return URL(
173
- protocol="https",
174
- host="app.open-cosmos.com",
175
- port=443,
176
- path="/api/data/v0/stac",
177
- )
178
- return stac_config
90
+ def _default_stac(cls, value: URL | None) -> URL:
91
+ return value or URL(**DEFAULT_STAC)
179
92
 
180
93
  @field_validator("datacosmos_cloud_storage", mode="before")
181
94
  @classmethod
182
- def validate_datacosmos_cloud_storage(
183
- cls, datacosmos_cloud_storage_config: Optional[URL]
184
- ) -> URL:
185
- """Ensure datacosmos cloud storage configuration has a default if not explicitly set.
186
-
187
- Args:
188
- datacosmos_cloud_storage_config (Optional[URL]): The datacosmos cloud storage config to validate.
189
-
190
- Returns:
191
- URL: The validated datacosmos cloud storage configuration.
192
- """
193
- if datacosmos_cloud_storage_config is None:
194
- return URL(
195
- protocol="https",
196
- host="app.open-cosmos.com",
197
- port=443,
198
- path="/api/data/v0/storage",
199
- )
200
- return datacosmos_cloud_storage_config
95
+ def _default_cloud_storage(cls, value: URL | None) -> URL:
96
+ return value or URL(**DEFAULT_STORAGE)
201
97
 
202
98
  @field_validator("datacosmos_public_cloud_storage", mode="before")
203
99
  @classmethod
204
- def validate_datacosmos_public_cloud_storage(
205
- cls, datacosmos_public_cloud_storage_config: Optional[URL]
206
- ) -> URL:
207
- """Ensure datacosmos cloud storage configuration has a default if not explicitly set.
208
-
209
- Args:
210
- datacosmos_public_cloud_storage_config (Optional[URL]): The datacosmos public cloud storage config to validate.
211
-
212
- Returns:
213
- URL: The validated datacosmos public cloud storage configuration.
214
- """
215
- if datacosmos_public_cloud_storage_config is None:
216
- return URL(
217
- protocol="https",
218
- host="app.open-cosmos.com",
219
- port=443,
220
- path="/api/data/v0/storage",
221
- )
222
- return datacosmos_public_cloud_storage_config
100
+ def _default_public_cloud_storage(cls, value: URL | None) -> URL:
101
+ return value or URL(**DEFAULT_STORAGE)
@@ -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
- type: Literal["local"]
20
- client_id: str
21
- authorization_endpoint: str
22
- token_endpoint: str
23
- redirect_port: int
24
- scopes: str
25
- audience: str
26
- cache_file: str
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, Field
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
- DEFAULT_TYPE: Literal["m2m"] = "m2m"
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"] = Field(default=DEFAULT_TYPE)
24
- client_id: str
25
- token_url: str = Field(default=DEFAULT_TOKEN_URL)
26
- audience: str = Field(default=DEFAULT_AUDIENCE)
27
- client_secret: str
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
@@ -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._http_client = http_session
31
- self._owns_session = False
32
- if isinstance(http_session, OAuth2Session):
33
- token_data = http_session.token
34
- elif isinstance(http_session, requests.Session):
35
- auth_header = http_session.headers.get("Authorization", "")
36
- if not auth_header.startswith("Bearer "):
37
- raise DatacosmosException(
38
- "Injected requests.Session must include a 'Bearer' token in its headers"
39
- )
40
- token_data = {"access_token": auth_header.split(" ", 1)[1]}
41
- else:
42
- raise DatacosmosException(
43
- f"Unsupported session type: {type(http_session)}"
44
- )
45
- try:
46
- self.token = token_data.get("access_token")
47
- self.token_expiry = token_data.get("expires_at") or token_data.get(
48
- "expires_in"
49
- )
50
- except Exception:
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
- "Failed to extract token from injected session"
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
- self.config = config
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()
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
- self._owns_session = True
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
- """Authenticate and initialize the HTTP client with a valid token."""
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=self.config.authentication.token_url,
80
- client_id=self.config.authentication.client_id,
81
- client_secret=self.config.authentication.client_secret,
82
- audience=self.config.authentication.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
- self.token_expiry = datetime.now(timezone.utc) + timedelta(
87
- seconds=token_response.get("expires_in", 3600)
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: {str(e)}") from e
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: {str(e)}"
146
+ f"Unexpected request failure during authentication: {e}"
98
147
  ) from e
99
148
 
100
- def _refresh_token_if_needed(self):
101
- """Refresh the token if it has expired (only if SDK created it)."""
102
- if self._owns_session and (
103
- not self.token or self.token_expiry <= datetime.now(timezone.utc)
104
- ):
105
- self._http_client = self._authenticate_and_initialize_client()
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.response,
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}: {str(e)}"
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}: {str(e)}"
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}: {str(e)}"
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.12
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=3BurTz1fPk1Dzp8B5xt5gZZrFiqk1AT5oaqKeYmXPec,6517
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=3iet6ou0vrcreaJjIFn0s59fyXHnolf026r1qe9PEvY,8487
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=8WApn720MBXMKQa6w7bCd7Z37GRmYR-I7mBUgUI20lQ,701
8
- datacosmos/config/models/m2m_authentication_config.py,sha256=n76N4bakpPPycTOeKpiM8pazYtNqiJGMzZXmI_ogbHM,847
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.12.dist-info/licenses/LICENSE.md,sha256=vpbRI-UUbZVQfr3VG_CXt9HpRnL1b5kt8uTVbirxeyI,1486
48
- datacosmos-0.0.12.dist-info/METADATA,sha256=HvFc03wc5mu0XRmfl5Xw7iP8qxyC7Cmz-VFXQJjxQuo,897
49
- datacosmos-0.0.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
50
- datacosmos-0.0.12.dist-info/top_level.txt,sha256=ueobs5CNeyDbPMgXPcVV0d0yNdm8CvGtDT3CaksRVtA,11
51
- datacosmos-0.0.12.dist-info/RECORD,,
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,,