deeporigin-sdk 4.7.0__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.
deeporigin_sdk/VERSION ADDED
@@ -0,0 +1 @@
1
+ 4.7.0
@@ -0,0 +1,49 @@
1
+ import os
2
+ import pathlib
3
+ from pathlib import Path
4
+ import subprocess
5
+
6
+ from deeporigin_sdk.exceptions import DeepOriginException
7
+
8
+ __all__ = ["__version__", "DeepOriginException"]
9
+
10
+ SRC_DIR = pathlib.Path(__file__).parent
11
+
12
+ version_filename = os.path.join(SRC_DIR, "VERSION")
13
+
14
+
15
+ def _get_pep440_version():
16
+ """get a pep440-compliant local version number"""
17
+ result = subprocess.run(
18
+ ["git", "describe", "--tags", "--dirty", "--long"],
19
+ capture_output=True,
20
+ universal_newlines=True,
21
+ cwd=Path(__file__).parent.parent,
22
+ )
23
+ describe_str = result.stdout.strip()
24
+
25
+ # Parse the git describe output
26
+ tag, commits, commit_hash_dirty = describe_str.rsplit("-", 2)
27
+ commit_hash, dirty = (commit_hash_dirty.split("-") + [""])[:2]
28
+
29
+ # Format to PEP 440
30
+ if dirty:
31
+ local_version = f"+{commits}.g{commit_hash}.dirty"
32
+ else:
33
+ local_version = f"+{commits}.g{commit_hash}"
34
+
35
+ pep440_version = f"{tag}{local_version}"
36
+
37
+ return pep440_version
38
+
39
+
40
+ with open(version_filename, "r") as file:
41
+ __version__ = file.read().strip()
42
+
43
+ if __version__ == "0.0.0.dev0":
44
+ # in dev mode. we use git to get a "version number"
45
+ # that will change with tags and commits, if possible
46
+ try:
47
+ __version__ = _get_pep440_version()
48
+ except Exception:
49
+ pass
deeporigin_sdk/auth.py ADDED
@@ -0,0 +1,290 @@
1
+ """this module handles authentication actions and interactions
2
+ with tokens"""
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import time
8
+
9
+ from beartype import beartype
10
+ import httpx
11
+ import jwt
12
+
13
+ from deeporigin_sdk.config import get_value as get_config
14
+ from deeporigin_sdk.exceptions import DeepOriginException
15
+ from deeporigin_sdk.utils.constants import ENV_VARIABLES, ENVS
16
+ from deeporigin_sdk.utils.core import (
17
+ _ensure_do_folder,
18
+ _supports_unicode_output,
19
+ )
20
+
21
+ __all__ = [
22
+ "get_token",
23
+ "save_token",
24
+ ]
25
+
26
+ AUTH_DOMAIN = {
27
+ "dev": "https://login.dev.deeporigin.io",
28
+ "prod": "https://login.deeporigin.io",
29
+ "staging": "https://login.staging.deeporigin.io",
30
+ }
31
+
32
+
33
+ @beartype
34
+ def _get_api_tokens_filepath() -> Path:
35
+ """get location of the api tokens file"""
36
+
37
+ return _ensure_do_folder() / "api_tokens.json"
38
+
39
+
40
+ @beartype
41
+ def read_cached_token(*, env: ENVS | None = None) -> str | None:
42
+ """Read cached API access token for a specific environment.
43
+
44
+ Args:
45
+ env: Environment name (e.g., 'prod', 'staging', 'edge').
46
+ If None, reads from config.
47
+
48
+ Returns:
49
+ Access token string for the specified environment.
50
+ Returns None if token does not exist for that environment.
51
+ """
52
+ if env is None:
53
+ env = get_config()["env"]
54
+
55
+ filepath = _get_api_tokens_filepath()
56
+
57
+ if not filepath.exists():
58
+ return None
59
+
60
+ with open(filepath, "r") as file:
61
+ all_tokens = json.load(file)
62
+
63
+ # Return access token for the specific environment
64
+ return all_tokens.get(env)
65
+
66
+
67
+ @beartype
68
+ def tokens_exist(*, env: ENVS | None = None) -> bool:
69
+ """Check if cached API token exist for a specific environment.
70
+
71
+ Args:
72
+ env: Environment name. If None, checks for current config environment.
73
+
74
+ Returns:
75
+ True if token exists for the environment, False otherwise.
76
+ """
77
+ if env is None:
78
+ env = get_config()["env"]
79
+
80
+ filepath = _get_api_tokens_filepath()
81
+
82
+ if not filepath.exists():
83
+ return False
84
+
85
+ with open(filepath, "r") as file:
86
+ all_tokens = json.load(file)
87
+
88
+ return bool(env in all_tokens and all_tokens[env])
89
+
90
+
91
+ @beartype
92
+ def get_token(*, env: ENVS | None = None) -> str:
93
+ """Get access token for accessing the Deep Origin API
94
+
95
+ Gets token to access Deep Origin API.
96
+
97
+
98
+ If an access token exists in the ENV, then it is used before
99
+ anything else. If not, then the tokens file is
100
+ checked for access token, and used if they exist.
101
+
102
+ Args:
103
+ env: Environment name. If None, uses current config environment.
104
+
105
+ Returns:
106
+ API access token string
107
+ """
108
+ if env is None:
109
+ env = get_config()["env"]
110
+
111
+ token = None
112
+
113
+ # Try to read from disk first
114
+ if tokens_exist(env=env):
115
+ token = read_cached_token(env=env)
116
+
117
+ # tokens in env override tokens on disk
118
+ if ENV_VARIABLES["access_token"] in os.environ:
119
+ token = os.environ[ENV_VARIABLES["access_token"]]
120
+
121
+ if not token:
122
+ raise DeepOriginException(
123
+ "No access token found. Failed to get a token from the environment or disk."
124
+ )
125
+
126
+ # check if the access token is expired
127
+ if is_token_expired(token):
128
+ raise DeepOriginException(
129
+ title="Token Expired",
130
+ message="Token is expired. Please refer to https://client-docs.deeporigin.io/how-to/auth.html to get a new token.",
131
+ level="danger",
132
+ )
133
+
134
+ return token
135
+
136
+
137
+ @beartype
138
+ def token_to_env(token: str) -> ENVS:
139
+ """Determine the environment from a token's issuer.
140
+
141
+ Args:
142
+ token: Access token string.
143
+
144
+ Returns:
145
+ Environment name ('dev', 'staging', or 'prod').
146
+ """
147
+ decoded_token = decode_access_token(token)
148
+ if "dev" in decoded_token["iss"]:
149
+ return "dev"
150
+ elif "staging" in decoded_token["iss"]:
151
+ return "staging"
152
+ elif "local" in decoded_token["iss"]:
153
+ return "local"
154
+ else:
155
+ return "prod"
156
+
157
+
158
+ @beartype
159
+ def save_token(token: str) -> None:
160
+ """Save a long-lived token from the UI to disk.
161
+
162
+ This function validates and saves a long-lived token obtained from the
163
+ Deep Origin UI. The token will be stored in the api_tokens.json file
164
+ and used by get_token() and client initialization.
165
+
166
+ Args:
167
+ token: Long-lived token string obtained from the UI.
168
+
169
+
170
+ Raises:
171
+ DeepOriginException: If token is invalid or cannot be decoded.
172
+ """
173
+
174
+ env = token_to_env(token)
175
+ decoded_token = decode_access_token(token)
176
+
177
+ # Save tokens to disk
178
+ filepath = _get_api_tokens_filepath()
179
+
180
+ # Load existing tokens for all environments
181
+ all_tokens = {}
182
+ if filepath.exists():
183
+ with open(filepath, "r") as file:
184
+ all_tokens = json.load(file)
185
+
186
+ # Update tokens for the specific environment
187
+ all_tokens[env] = token
188
+
189
+ # Write back all environments
190
+ with open(filepath, "w") as file:
191
+ json.dump(all_tokens, file, indent=2)
192
+
193
+ name = decoded_token.get("name", "Unknown User")
194
+
195
+ # Print confirmation
196
+ if _supports_unicode_output():
197
+ check = "✔︎"
198
+ else:
199
+ check = "OK"
200
+ print(
201
+ f"{check} Long-lived token for {name} saved successfully for environment '{env}'"
202
+ )
203
+
204
+
205
+ @beartype
206
+ def is_token_expired(token: str) -> bool:
207
+ """
208
+ Check if the JWT token is expired. The token is expected to have an 'exp' field as a Unix timestamp.
209
+
210
+ Args:
211
+ token: The JWT token string to check.
212
+
213
+ Returns:
214
+ bool: True if the token is expired, False otherwise.
215
+ """
216
+ decoded_token = decode_access_token(token)
217
+ # Get the expiration time from the token, defaulting to 0 if not found.
218
+ exp_time = decoded_token.get("exp", 0)
219
+ current_time = time.time() # Get current time in seconds since the epoch.
220
+
221
+ # If current time is greater than the expiration time, it's expired.
222
+ return current_time > exp_time
223
+
224
+
225
+ @beartype
226
+ def decode_access_token(token: str) -> dict:
227
+ """decode access token into human readable data"""
228
+
229
+ # Get the JWT header
230
+ header = jwt.get_unverified_header(token)
231
+
232
+ # Decode the JWT using the public key
233
+ return jwt.decode(
234
+ token, algorithms=header["alg"], options={"verify_signature": False}
235
+ )
236
+
237
+
238
+ @beartype
239
+ def _get_keycloak_token(
240
+ *,
241
+ email: str,
242
+ password: str,
243
+ realm: str = "deeporigin",
244
+ base_url: str = "https://login.dev.deeporigin.io",
245
+ scope: str = "openid email super-user",
246
+ ) -> dict:
247
+ """get a token, with optional super user scope from keycloak
248
+
249
+ This returns a super-user token (if possible) from keycloak. Do not use this function.
250
+
251
+ Args:
252
+ email: the email of the super user
253
+ password: the password of the super user
254
+ realm: the realm to get the token from
255
+ base_url: the base url of the keycloak instance
256
+ scope: the scope of the token
257
+
258
+ Raises:
259
+ DeepOriginException: If email or password is empty or not a string.
260
+ """
261
+ # Validate input parameters
262
+ if not email.strip():
263
+ raise DeepOriginException(
264
+ title="Invalid email parameter",
265
+ message="Email must be a non-empty string.",
266
+ )
267
+ if not password.strip():
268
+ raise DeepOriginException(
269
+ title="Invalid password parameter",
270
+ message="Password must be a non-empty string.",
271
+ )
272
+
273
+ keycloak_url = f"{base_url}/realms/{realm}/protocol/openid-connect/token"
274
+
275
+ data = {
276
+ "grant_type": "password",
277
+ "username": email,
278
+ "password": password,
279
+ "client_id": "do-app",
280
+ "scope": scope,
281
+ }
282
+
283
+ response = httpx.post(
284
+ keycloak_url,
285
+ data=data, # sent as application/x-www-form-urlencoded
286
+ # Let httpx set Content-Type automatically for form data
287
+ )
288
+
289
+ response.raise_for_status()
290
+ return response.json()
@@ -0,0 +1,184 @@
1
+ """Simplified configuration management for Deep Origin client.
2
+
3
+ This module stores and retrieves only two configuration values:
4
+ `env` and `org_key`.
5
+
6
+ Behavior:
7
+ - If the config file does not exist, it is created with `env=prod` and an
8
+ empty `org_key`.
9
+ - If the config file exists, it is read and a dictionary is returned.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ from typing import TYPE_CHECKING, Literal
15
+
16
+ if TYPE_CHECKING:
17
+ import pandas as pd
18
+
19
+ from deeporigin_sdk.utils.constants import ENV_VARIABLES
20
+ from deeporigin_sdk.utils.core import _ensure_do_folder, _supports_unicode_output
21
+
22
+ CONFIG_JSON_LOCATION = _ensure_do_folder() / "config.json"
23
+
24
+ __all__ = [
25
+ "get_org",
26
+ "set_org",
27
+ "get_env",
28
+ "set_env",
29
+ "get_value",
30
+ "CONFIG_JSON_LOCATION",
31
+ ]
32
+
33
+
34
+ def _ensure_config_file_exists() -> None:
35
+ """Ensure the configuration file exists; create with defaults if missing."""
36
+
37
+ if not os.path.isfile(CONFIG_JSON_LOCATION):
38
+ default_data: dict = {"env": "prod", "org_key": ""}
39
+ os.makedirs(os.path.dirname(CONFIG_JSON_LOCATION), exist_ok=True)
40
+ with open(CONFIG_JSON_LOCATION, "w") as file:
41
+ json.dump(default_data, file, indent=2)
42
+
43
+
44
+ def get_org() -> str | None:
45
+ """Get the organization key.
46
+
47
+ Creates the config file with defaults if it doesn't exist.
48
+
49
+ Returns:
50
+ The organization key, or None if not set. Environment variables
51
+ override the config file value.
52
+ """
53
+ return get_value()["org_key"]
54
+
55
+
56
+ def set_org(value: str) -> None:
57
+ """Set the organization key.
58
+
59
+ Args:
60
+ value: The organization key to set.
61
+
62
+ Raises:
63
+ DeepOriginException: If the organization key does not exist in the
64
+ list of accessible organizations.
65
+ """
66
+ from deeporigin_sdk.exceptions import DeepOriginException
67
+
68
+ # Validate that the org key exists
69
+ orgs_df = list_orgs()
70
+ if value not in orgs_df["key"].values:
71
+ available_keys = ", ".join(orgs_df["key"].tolist())
72
+ raise DeepOriginException(
73
+ title="Invalid organization key",
74
+ message=f"Organization key '{value}' not found in accessible organizations.",
75
+ fix=f"Available organization keys: {available_keys}",
76
+ level="danger",
77
+ )
78
+
79
+ _set_value("org_key", value)
80
+
81
+
82
+ def get_env() -> str:
83
+ """Get the environment.
84
+
85
+ Creates the config file with defaults if it doesn't exist.
86
+
87
+ Returns:
88
+ The environment (e.g., 'prod', 'staging', 'edge', 'dev', 'local'). Defaults to 'prod'.
89
+ Environment variables override the config file value.
90
+ """
91
+ return get_value()["env"]
92
+
93
+
94
+ def set_env(value: str) -> None:
95
+ """Set the environment.
96
+
97
+ Args:
98
+ value: The environment to set (e.g., 'prod', 'staging', 'edge', 'dev', 'local').
99
+ """
100
+ _set_value("env", value)
101
+
102
+
103
+ def _set_value(key: Literal["env", "org_key"], value) -> None:
104
+ """Internal helper to set a configuration value.
105
+
106
+ Args:
107
+ key: Configuration key to set (must be 'env' or 'org_key').
108
+ value: Value to set.
109
+ """
110
+ _ensure_config_file_exists()
111
+
112
+ with open(CONFIG_JSON_LOCATION, "r") as file:
113
+ data = json.load(file) or {}
114
+
115
+ data[key] = value
116
+
117
+ # Persist updated data
118
+ with open(CONFIG_JSON_LOCATION, "w") as file:
119
+ json.dump(data, file, indent=2)
120
+
121
+ # Prefer Unicode on capable terminals; fall back to ASCII-safe symbols
122
+ if _supports_unicode_output():
123
+ check, arrow = "✔︎", "→"
124
+ else:
125
+ check, arrow = "OK", "->"
126
+ print(f"{check} {key} {arrow} {value}")
127
+
128
+
129
+ def get_value() -> dict:
130
+ """Get the configuration values.
131
+
132
+ Creates the file with defaults if it doesn't exist, then returns a dict
133
+ with keys `env` and `org_key`.
134
+
135
+ Args:
136
+ config_file_location: Optional custom path for the config file.
137
+
138
+ Returns:
139
+ A dictionary with keys `env` and `org_key`.
140
+ """
141
+
142
+ _ensure_config_file_exists()
143
+
144
+ with open(CONFIG_JSON_LOCATION, "r") as file:
145
+ data = json.load(file) or {}
146
+
147
+ # Fill defaults if missing
148
+ env = data.get("env", "prod")
149
+ org_key = data.get("org_key", None)
150
+
151
+ # env variables override config file
152
+ if ENV_VARIABLES["env"] in os.environ:
153
+ env = os.environ[ENV_VARIABLES["env"]]
154
+ if ENV_VARIABLES["org_key"] in os.environ:
155
+ org_key = os.environ[ENV_VARIABLES["org_key"]]
156
+
157
+ return {"env": env, "org_key": org_key}
158
+
159
+
160
+ def list_orgs() -> "pd.DataFrame":
161
+ """List all organizations accessible to the authenticated user.
162
+
163
+ Returns:
164
+ A pandas DataFrame with columns: name, key, autoApproveMaxAmount, threshold.
165
+ """
166
+ import pandas as pd
167
+
168
+ from deeporigin_sdk.platform.client import DeepOriginClient
169
+
170
+ client = DeepOriginClient.get()
171
+ orgs = client.organizations.list()
172
+
173
+ # Extract only the required columns and map orgKey to key
174
+ data = [
175
+ {
176
+ "name": org["name"],
177
+ "key": org["orgKey"],
178
+ "autoApproveMaxAmount": org["autoApproveMaxAmount"],
179
+ "threshold": org["threshold"],
180
+ }
181
+ for org in orgs
182
+ ]
183
+
184
+ return pd.DataFrame(data)
@@ -0,0 +1,125 @@
1
+ """custom exceptions to surface better errors in notebooks"""
2
+
3
+ import sys
4
+
5
+ from deeporigin_sdk.utils.core import _supports_color
6
+
7
+ __all__ = ["DeepOriginException", "install_silent_error_handler"]
8
+
9
+
10
+ class DeepOriginException(Exception):
11
+ """Stops execution without showing a traceback, displays a styled error card."""
12
+
13
+ def __init__(self, title="Error", message=None, fix=None, level="danger"):
14
+ super().__init__(message or title)
15
+ self.title = title
16
+ self.body = message or ""
17
+ self.footer = fix
18
+ # accepted: danger | warning | info | success | secondary
19
+ self.level = level
20
+
21
+ def __str__(self) -> str:
22
+ """Format exception for display. Returns minimal output in notebooks (where HTML rendering is handled separately) or formatted console output otherwise."""
23
+ # Try to use IPython display if available (for notebooks)
24
+ try:
25
+ from IPython import get_ipython
26
+
27
+ ip = get_ipython()
28
+ if ip is not None and "pytest" not in sys.modules:
29
+ # In notebook, let the custom handler display HTML
30
+ # Return minimal string to avoid double display
31
+ return self.title
32
+ except ImportError:
33
+ # IPython is not available; fall back to console output formatting below.
34
+ pass
35
+
36
+ # Format for console output
37
+ lines = []
38
+
39
+ # Add colored title if terminal supports it
40
+ if _supports_color():
41
+ level_colors = {
42
+ "danger": "\033[91m", # Red
43
+ "warning": "\033[93m", # Yellow
44
+ "info": "\033[94m", # Blue
45
+ "success": "\033[92m", # Green
46
+ "secondary": "\033[90m", # Gray
47
+ }
48
+ reset = "\033[0m"
49
+ color = level_colors.get(self.level, reset)
50
+ lines.append(f"{color}╔═ {self.title} ═╗{reset}")
51
+ else:
52
+ lines.append(f"╔═ {self.title} ═╗")
53
+
54
+ # Add body
55
+ if self.body:
56
+ lines.append(self.body)
57
+
58
+ # Add footer/fix if present
59
+ if self.footer:
60
+ if _supports_color():
61
+ lines.append(f"\033[2m{self.footer}\033[0m") # Dimmed text
62
+ else:
63
+ lines.append(self.footer)
64
+
65
+ return "\n".join(lines)
66
+
67
+
68
+ def _silent_error_handler(shell, etype, evalue, tb, tb_offset=None):
69
+ """Display a styled error card using Bootstrap 5.3.0."""
70
+ try:
71
+ from IPython.display import HTML, display
72
+ except ImportError:
73
+ # Fallback to console output if IPython not available
74
+ print(str(evalue), file=sys.stderr)
75
+ return []
76
+
77
+ footer_html = (
78
+ f'<div class="card-footer text-muted">{evalue.footer}</div>'
79
+ if evalue.footer
80
+ else ""
81
+ )
82
+
83
+ html = f"""
84
+ <!DOCTYPE html>
85
+ <html lang="en">
86
+ <head>
87
+ <meta charset="utf-8">
88
+ <meta name="viewport" content="width=device-width, initial-scale=1">
89
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
90
+ </head>
91
+ <body>
92
+ <div class="container-fluid px-0">
93
+ <div class="card border-{evalue.level} mb-3 shadow-sm" style="max-width: 42rem;">
94
+ <div class="card-header bg-{evalue.level} text-white fw-bold">
95
+ {evalue.title}
96
+ </div>
97
+ <div class="card-body">
98
+ <div class="card-text">
99
+ {evalue.body}
100
+ </div>
101
+ </div>
102
+ {footer_html}
103
+ </div>
104
+ </div>
105
+ </body>
106
+ </html>
107
+ """
108
+ display(HTML(html))
109
+ return [] # suppress traceback completely
110
+
111
+
112
+ def install_silent_error_handler() -> bool:
113
+ """Install a custom error handler for IPython notebooks that displays a styled error card."""
114
+ try:
115
+ from IPython import get_ipython
116
+ except ImportError:
117
+ return False
118
+ ip = get_ipython()
119
+ if ip is None or "pytest" in sys.modules:
120
+ return False
121
+ ip.set_custom_exc((DeepOriginException,), _silent_error_handler)
122
+ return True
123
+
124
+
125
+ install_silent_error_handler()
@@ -0,0 +1,23 @@
1
+ """Platform client module.
2
+
3
+ Provides the `DeepOriginClient` used to interact with the Deep Origin platform.
4
+
5
+ This module supports configuration via keyword arguments or the following
6
+ environment variables when keywords are omitted:
7
+
8
+ - `DEEPORIGIN_TOKEN`
9
+ - `DEEPORIGIN_ENV` (defaults to "prod" if not provided)
10
+ - `DEEPORIGIN_ORG_KEY`
11
+
12
+ The client automatically caches instances based on (base_url, token, org_key, tag),
13
+ so calling `DeepOriginClient()` multiple times with the same parameters returns
14
+ the same cached instance, reusing connection pools.
15
+
16
+ Example:
17
+ client = DeepOriginClient() # Uses singleton cache automatically
18
+ client.tag = "my-tag" # Set tag for all function runs
19
+ """
20
+
21
+ from deeporigin_sdk.platform.client import DeepOriginClient
22
+
23
+ __all__ = ["DeepOriginClient"]