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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

@@ -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,7 @@
1
1
  """Client to interact with the Datacosmos API with authentication and request handling."""
2
2
 
3
3
  from datetime import datetime, timedelta, timezone
4
+ from pathlib import Path
4
5
  from typing import Any, Optional
5
6
 
6
7
  import requests
@@ -23,86 +24,183 @@ class DatacosmosClient:
23
24
  """Initialize the DatacosmosClient.
24
25
 
25
26
  Args:
26
- config (Optional[Config]): Configuration object (only needed when SDK creates its own session).
27
- http_session (Optional[requests.Session]): Pre-authenticated session.
27
+ config: SDK configuration (if omitted, Config() loads YAML + env).
28
+ http_session: Pre-authenticated session (OAuth2Session or requests.Session
29
+ with 'Authorization: Bearer ...').
28
30
  """
31
+ self.config = self._coerce_config(config)
32
+ self.token: Optional[str] = None
33
+ self.token_expiry: Optional[datetime] = None
34
+
29
35
  if http_session is not None:
30
- self._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:
36
+ self._init_with_injected_session(http_session)
37
+ return
38
+
39
+ self._owns_session = True
40
+ self._http_client = self._authenticate_and_initialize_client()
41
+
42
+ # --------------------------- init helpers ---------------------------
43
+
44
+ def _coerce_config(self, cfg: Optional[Config | Any]) -> Config:
45
+ """Normalize various config inputs into a Config instance."""
46
+ if cfg is None:
47
+ return Config()
48
+ if isinstance(cfg, Config):
49
+ return cfg
50
+ if isinstance(cfg, dict):
51
+ return Config(**cfg)
52
+ try:
53
+ return Config.model_validate(cfg) # pydantic v2
54
+ except Exception as e:
55
+ raise DatacosmosException(
56
+ "Invalid config provided to DatacosmosClient"
57
+ ) from e
58
+
59
+ def _init_with_injected_session(
60
+ self, http_session: requests.Session | OAuth2Session
61
+ ) -> None:
62
+ """Adopt a caller-provided session and extract token/expiry."""
63
+ self._http_client = http_session
64
+ self._owns_session = False
65
+
66
+ token_data = self._extract_token_data(http_session)
67
+ self.token = token_data.get("access_token")
68
+ if not self.token:
69
+ raise DatacosmosException(
70
+ "Failed to extract access token from injected session"
71
+ )
72
+
73
+ self.token_expiry = self._compute_expiry(
74
+ token_data.get("expires_at"),
75
+ token_data.get("expires_in"),
76
+ )
77
+
78
+ def _extract_token_data(
79
+ self, http_session: requests.Session | OAuth2Session
80
+ ) -> dict:
81
+ """Return {'access_token', 'expires_at'?, 'expires_in'?} from the session."""
82
+ if isinstance(http_session, OAuth2Session):
83
+ return getattr(http_session, "token", {}) or {}
84
+
85
+ if isinstance(http_session, requests.Session):
86
+ auth_header = http_session.headers.get("Authorization", "")
87
+ if not auth_header.startswith("Bearer "):
42
88
  raise DatacosmosException(
43
- f"Unsupported session type: {type(http_session)}"
89
+ "Injected requests.Session must include a 'Bearer' token in its headers"
44
90
  )
91
+ return {"access_token": auth_header.split(" ", 1)[1]}
92
+
93
+ raise DatacosmosException(f"Unsupported session type: {type(http_session)}")
94
+
95
+ def _compute_expiry(
96
+ self,
97
+ expires_at: Optional[datetime | int | float],
98
+ expires_in: Optional[int | float],
99
+ ) -> Optional[datetime]:
100
+ """Normalize expiry inputs to an absolute UTC datetime (or None)."""
101
+ if isinstance(expires_at, datetime):
102
+ return expires_at
103
+ if isinstance(expires_at, (int, float)):
104
+ return datetime.fromtimestamp(expires_at, tz=timezone.utc)
105
+ if expires_in is not None:
45
106
  try:
46
- 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:
51
- raise DatacosmosException(
52
- "Failed to extract token from injected session"
53
- )
107
+ return datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
108
+ except (TypeError, ValueError):
109
+ return None
110
+ return None
54
111
 
55
- 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()
64
-
65
- self._owns_session = True
66
- self.token = None
67
- self.token_expiry = None
68
- self._http_client = self._authenticate_and_initialize_client()
112
+ # --------------------------- auth/session ---------------------------
69
113
 
70
114
  def _authenticate_and_initialize_client(self) -> requests.Session:
71
115
  """Authenticate and initialize the HTTP client with a valid token."""
116
+ auth = self.config.authentication
117
+ auth_type = getattr(auth, "type", "m2m")
118
+
119
+ if auth_type == "m2m":
120
+ return self.__build_m2m_session()
121
+
122
+ if auth_type == "local":
123
+ return self.__build_local_session()
124
+
125
+ raise DatacosmosException(f"Unsupported authentication type: {auth_type}")
126
+
127
+ def _refresh_token_if_needed(self):
128
+ """Refresh the token if it has expired (only if SDK created it)."""
129
+ if not getattr(self, "_owns_session", False):
130
+ return
131
+ now = datetime.now(timezone.utc)
132
+ # Treat missing token or missing expiry as 'needs refresh'
133
+ if (
134
+ (not self.token)
135
+ or (self.token_expiry is None)
136
+ or (self.token_expiry <= now)
137
+ ):
138
+ self._http_client = self._authenticate_and_initialize_client()
139
+
140
+ def __build_m2m_session(self) -> requests.Session:
141
+ """Client Credentials (M2M) flow using requests-oauthlib."""
142
+ auth = self.config.authentication
72
143
  try:
73
- client = BackendApplicationClient(
74
- client_id=self.config.authentication.client_id
75
- )
144
+ client = BackendApplicationClient(client_id=auth.client_id)
76
145
  oauth_session = OAuth2Session(client=client)
77
146
 
78
147
  token_response = oauth_session.fetch_token(
79
- token_url=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,
148
+ token_url=auth.token_url,
149
+ client_id=auth.client_id,
150
+ client_secret=auth.client_secret,
151
+ audience=auth.audience,
83
152
  )
84
153
 
85
154
  self.token = token_response["access_token"]
86
- self.token_expiry = datetime.now(timezone.utc) + timedelta(
87
- seconds=token_response.get("expires_in", 3600)
88
- )
155
+ expires_at = token_response.get("expires_at")
156
+ if isinstance(expires_at, (int, float)):
157
+ self.token_expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc)
158
+ else:
159
+ self.token_expiry = datetime.now(timezone.utc) + timedelta(
160
+ seconds=int(token_response.get("expires_in", 3600))
161
+ )
89
162
 
90
163
  http_client = requests.Session()
91
164
  http_client.headers.update({"Authorization": f"Bearer {self.token}"})
92
165
  return http_client
166
+
93
167
  except (HTTPError, ConnectionError, Timeout) as e:
94
- raise DatacosmosException(f"Authentication failed: {str(e)}") from e
168
+ raise DatacosmosException(f"Authentication failed: {e}") from e
95
169
  except RequestException as e:
96
170
  raise DatacosmosException(
97
- f"Unexpected request failure during authentication: {str(e)}"
171
+ f"Unexpected request failure during authentication: {e}"
98
172
  ) from e
99
173
 
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()
174
+ def __build_local_session(self) -> requests.Session:
175
+ """Interactive local login via LocalTokenFetcher (cached + refresh)."""
176
+ auth = self.config.authentication
177
+ try:
178
+ from datacosmos.auth.local_token_fetcher import LocalTokenFetcher
179
+
180
+ fetcher = LocalTokenFetcher(
181
+ client_id=auth.client_id,
182
+ authorization_endpoint=auth.authorization_endpoint,
183
+ token_endpoint=auth.token_endpoint,
184
+ redirect_port=int(auth.redirect_port),
185
+ audience=auth.audience,
186
+ scopes=auth.scopes,
187
+ token_file=Path(auth.cache_file).expanduser(),
188
+ )
189
+ tok = fetcher.get_token()
190
+ except Exception as e:
191
+ raise DatacosmosException(f"Local authentication failed: {e}") from e
192
+
193
+ self.token = tok.access_token
194
+ self.token_expiry = datetime.fromtimestamp(tok.expires_at, tz=timezone.utc)
195
+
196
+ http_client = requests.Session()
197
+ http_client.headers.update({"Authorization": f"Bearer {self.token}"})
198
+
199
+ # keep for potential reuse in refresh path (optional)
200
+ self._local_token_fetcher = fetcher
201
+ return http_client
202
+
203
+ # --------------------------- request API ---------------------------
106
204
 
107
205
  def request(
108
206
  self, method: str, url: str, *args: Any, **kwargs: Any
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datacosmos
3
- Version: 0.0.12
3
+ Version: 0.0.13
4
4
  Summary: A library for interacting with DataCosmos from Python code
5
5
  Author-email: Open Cosmos <support@open-cosmos.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -20,6 +20,7 @@ Requires-Dist: black==22.3.0; extra == "dev"
20
20
  Requires-Dist: ruff==0.9.5; extra == "dev"
21
21
  Requires-Dist: pytest==7.2.0; extra == "dev"
22
22
  Requires-Dist: bandit[toml]==1.7.4; extra == "dev"
23
+ Requires-Dist: pbr>=6.0.0; extra == "dev"
23
24
  Requires-Dist: isort==5.11.4; extra == "dev"
24
25
  Requires-Dist: pydocstyle==6.1.1; extra == "dev"
25
26
  Dynamic: license-file
@@ -1,11 +1,18 @@
1
1
  datacosmos/__init__.py,sha256=dVHKpbz5FVtfoJAWHRdsUENG6H-vs4UrkuwnIvOGJr4,66
2
- datacosmos/datacosmos_client.py,sha256=3BurTz1fPk1Dzp8B5xt5gZZrFiqk1AT5oaqKeYmXPec,6517
2
+ datacosmos/datacosmos_client.py,sha256=qbKAzym-84ihLO9WiUgw15QbAEplxPmLc4BMUBtNoEg,10191
3
+ datacosmos/auth/__init__.py,sha256=ynCThS9QyLKV9miRdnjm8uF_breiGGiCcI0FaOSw_2o,45
4
+ datacosmos/auth/local_token_fetcher.py,sha256=E4MI2lTRHAmxIQA7qY6hmpAUZETTzXNO8MViBaXnxGs,5268
5
+ datacosmos/auth/token.py,sha256=neSV9gnnFa-rxEwMAJlZe_cReV6g4PQf8mq4-1mZzB8,2558
3
6
  datacosmos/config/__init__.py,sha256=KCsaTb9-ZgFui1GM8wZFIPLJy0D0O8l8Z1Sv3NRD9UM,140
4
- datacosmos/config/config.py,sha256=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.13.dist-info/licenses/LICENSE.md,sha256=vpbRI-UUbZVQfr3VG_CXt9HpRnL1b5kt8uTVbirxeyI,1486
55
+ datacosmos-0.0.13.dist-info/METADATA,sha256=8SZ4fh8VHOt8UXkmbu9WnDUX75CEyMdxm777mMZ_0vo,939
56
+ datacosmos-0.0.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
+ datacosmos-0.0.13.dist-info/top_level.txt,sha256=ueobs5CNeyDbPMgXPcVV0d0yNdm8CvGtDT3CaksRVtA,11
58
+ datacosmos-0.0.13.dist-info/RECORD,,