Datasphere-API 0.0.0__py3-none-any.whl → 0.1.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.
- datasphere_api/__init__.py +27 -0
- datasphere_api/auth.py +273 -0
- datasphere_api/client.py +219 -0
- datasphere_api/config.py +52 -0
- datasphere_api/exceptions.py +22 -0
- datasphere_api/models.py +305 -0
- datasphere_api/py.typed +0 -0
- datasphere_api/resources/__init__.py +0 -0
- datasphere_api/resources/analytical_models.py +631 -0
- datasphere_api/resources/base.py +19 -0
- datasphere_api/resources/remote_tables.py +213 -0
- datasphere_api/resources/task_chains.py +167 -0
- datasphere_api/resources/views.py +1248 -0
- datasphere_api-0.1.0.dist-info/METADATA +144 -0
- datasphere_api-0.1.0.dist-info/RECORD +17 -0
- {datasphere_api-0.0.0.dist-info → datasphere_api-0.1.0.dist-info}/WHEEL +1 -2
- datasphere_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- __init__.py +0 -2
- datasphere_api-0.0.0.dist-info/METADATA +0 -40
- datasphere_api-0.0.0.dist-info/RECORD +0 -5
- datasphere_api-0.0.0.dist-info/top_level.txt +0 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from datasphere_api.auth import TokenStore
|
|
4
|
+
from datasphere_api.client import DatasphereClient
|
|
5
|
+
from datasphere_api.config import Browser, DatasphereConfig
|
|
6
|
+
from datasphere_api.exceptions import (
|
|
7
|
+
AuthenticationFailed,
|
|
8
|
+
DatasphereException,
|
|
9
|
+
InvalidConfiguration,
|
|
10
|
+
MissingCredentials,
|
|
11
|
+
UnexpectedResponse,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Library logger stays silent unless the consumer adds handlers
|
|
15
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AuthenticationFailed",
|
|
19
|
+
"Browser",
|
|
20
|
+
"DatasphereClient",
|
|
21
|
+
"DatasphereConfig",
|
|
22
|
+
"DatasphereException",
|
|
23
|
+
"InvalidConfiguration",
|
|
24
|
+
"MissingCredentials",
|
|
25
|
+
"TokenStore",
|
|
26
|
+
"UnexpectedResponse",
|
|
27
|
+
]
|
datasphere_api/auth.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import http.server
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import socketserver
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import parse_qs, quote, urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from platformdirs import user_data_dir
|
|
15
|
+
from playwright.async_api import async_playwright
|
|
16
|
+
|
|
17
|
+
from datasphere_api.config import BROWSER_MAPPING, DatasphereConfig
|
|
18
|
+
from datasphere_api.exceptions import AuthenticationFailed
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Raw token response of the OAuth token endpoint
|
|
23
|
+
type TokenDict = dict[str, Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TokenStore:
|
|
27
|
+
|
|
28
|
+
def __init__(self, path: Path | None = None):
|
|
29
|
+
"""
|
|
30
|
+
Initializes the token store. The store persists the raw token
|
|
31
|
+
response of the OAuth token endpoint as a JSON file so sessions
|
|
32
|
+
can be shared between consumers of this library.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path (Path | None, optional): Path of the token file.
|
|
36
|
+
Defaults to None, which
|
|
37
|
+
resolves to 'session.json' in
|
|
38
|
+
the user data directory of
|
|
39
|
+
'Datasphere'.
|
|
40
|
+
"""
|
|
41
|
+
if path is None:
|
|
42
|
+
path = Path(user_data_dir("Datasphere")) / "session.json"
|
|
43
|
+
self.path = path
|
|
44
|
+
|
|
45
|
+
def load(self) -> TokenDict | None:
|
|
46
|
+
"""
|
|
47
|
+
Loads the cached tokens from the token file. Deletes the file if
|
|
48
|
+
it cannot be parsed.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
TokenDict | None: Cached tokens if the file exists and is
|
|
52
|
+
valid, else None.
|
|
53
|
+
"""
|
|
54
|
+
if not self.path.is_file():
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
with open(self.path, encoding="utf-8") as token_file:
|
|
58
|
+
return json.load(token_file)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
logger.warning("Token file is corrupt. Deleting file...")
|
|
61
|
+
self.delete()
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def save(self, tokens: TokenDict) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Saves tokens to the token file. Creates the parent directory if
|
|
67
|
+
it doesn't exist yet.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
tokens (TokenDict): Tokens to save.
|
|
71
|
+
"""
|
|
72
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
with open(self.path, "w", encoding="utf-8") as token_file:
|
|
74
|
+
json.dump(tokens, token_file)
|
|
75
|
+
|
|
76
|
+
def delete(self) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Deletes the token file if it exists.
|
|
79
|
+
"""
|
|
80
|
+
self.path.unlink(missing_ok=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def refresh_tokens(
|
|
84
|
+
config: DatasphereConfig,
|
|
85
|
+
session: httpx.AsyncClient,
|
|
86
|
+
refresh_token: str,
|
|
87
|
+
) -> TokenDict | None:
|
|
88
|
+
"""
|
|
89
|
+
Requests new tokens from the token endpoint using a refresh token.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
config (DatasphereConfig): Configuration with the token URL and
|
|
93
|
+
the client credentials.
|
|
94
|
+
session (httpx.AsyncClient): Session to send the request with.
|
|
95
|
+
refresh_token (str): Refresh token of a previous login.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
TokenDict | None: New tokens if the refresh was successful, else
|
|
99
|
+
None (e.g. if the refresh token has expired).
|
|
100
|
+
"""
|
|
101
|
+
# Send refresh request
|
|
102
|
+
auth = httpx.BasicAuth(
|
|
103
|
+
username=config.client_id,
|
|
104
|
+
password=config.client_secret,
|
|
105
|
+
)
|
|
106
|
+
response = await session.post(
|
|
107
|
+
url=config.token_url,
|
|
108
|
+
data={
|
|
109
|
+
"grant_type": "refresh_token",
|
|
110
|
+
"refresh_token": refresh_token,
|
|
111
|
+
},
|
|
112
|
+
auth=auth,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Parse and validate response
|
|
116
|
+
try:
|
|
117
|
+
tokens = response.json()
|
|
118
|
+
except json.JSONDecodeError:
|
|
119
|
+
tokens = {}
|
|
120
|
+
if response.status_code != 200 or "access_token" not in tokens:
|
|
121
|
+
logger.warning(
|
|
122
|
+
"Refreshing tokens failed with status code %s.",
|
|
123
|
+
response.status_code,
|
|
124
|
+
)
|
|
125
|
+
return None
|
|
126
|
+
return tokens
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def authenticate_interactively(
|
|
130
|
+
config: DatasphereConfig,
|
|
131
|
+
session: httpx.AsyncClient,
|
|
132
|
+
) -> TokenDict:
|
|
133
|
+
"""
|
|
134
|
+
Runs the interactive OAuth authorization code flow. Starts a local
|
|
135
|
+
callback server to receive the authorization code and opens a
|
|
136
|
+
Playwright browser window for the user to log in. Exchanges the
|
|
137
|
+
received code for tokens at the token endpoint.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
config (DatasphereConfig): Configuration with the URLs, the
|
|
141
|
+
client credentials and the browser to
|
|
142
|
+
use for the login.
|
|
143
|
+
session (httpx.AsyncClient): Session to send the token request
|
|
144
|
+
with.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
AuthenticationFailed: If the login is not completed in time or
|
|
148
|
+
the token endpoint doesn't return tokens.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
TokenDict: Tokens returned by the token endpoint.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
class ReusableServer(socketserver.TCPServer):
|
|
155
|
+
allow_reuse_address = True # to allow immediate reuse of the port
|
|
156
|
+
|
|
157
|
+
# Mutable container to store the callback code and access it from
|
|
158
|
+
# different threads
|
|
159
|
+
callback: dict[str, str | None] = {"code": None}
|
|
160
|
+
|
|
161
|
+
# Async handling of the callback server using an event to signal when
|
|
162
|
+
# the code is received
|
|
163
|
+
loop = asyncio.get_running_loop()
|
|
164
|
+
received = asyncio.Event()
|
|
165
|
+
|
|
166
|
+
@contextmanager
|
|
167
|
+
def callback_server(port: int) -> Iterator[dict[str, str | None]]:
|
|
168
|
+
"""
|
|
169
|
+
Context manager for the callback server. Everything before the
|
|
170
|
+
yield is treated as __enter__. Everything after the yield is
|
|
171
|
+
treated as __exit__.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
port (int): Port to listen on.
|
|
175
|
+
|
|
176
|
+
Yields:
|
|
177
|
+
dict[str, str | None]: Mapping of the callback code received
|
|
178
|
+
in a GET-request. The key is 'code'.
|
|
179
|
+
The initial value is None.
|
|
180
|
+
"""
|
|
181
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
182
|
+
def do_GET(self):
|
|
183
|
+
"""
|
|
184
|
+
Checks the query params of an incoming GET-request for a
|
|
185
|
+
'code' parameter. Assigns the value to the 'code' key in
|
|
186
|
+
the callback dict and displays a short confirmation in
|
|
187
|
+
the browser.
|
|
188
|
+
"""
|
|
189
|
+
params = parse_qs(urlparse(self.path).query)
|
|
190
|
+
callback_code = params.get("code", [None])[0]
|
|
191
|
+
if callback_code:
|
|
192
|
+
callback["code"] = callback_code
|
|
193
|
+
self.send_response(200)
|
|
194
|
+
self.send_header(
|
|
195
|
+
"Content-Type",
|
|
196
|
+
"text/html; charset=utf-8",
|
|
197
|
+
)
|
|
198
|
+
self.end_headers()
|
|
199
|
+
self.wfile.write(
|
|
200
|
+
b"<h1>Code received</h1>"
|
|
201
|
+
b"<p>This window will be closed automatically.</p>"
|
|
202
|
+
)
|
|
203
|
+
loop.call_soon_threadsafe(received.set)
|
|
204
|
+
|
|
205
|
+
def log_message(self, *args, **kwargs):
|
|
206
|
+
"""
|
|
207
|
+
Overrides the default logs to hide output to the console.
|
|
208
|
+
"""
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# Starts server in separate thread to not block the main thread
|
|
212
|
+
with ReusableServer(("localhost", port), Handler) as server:
|
|
213
|
+
thread = threading.Thread(
|
|
214
|
+
target=server.serve_forever,
|
|
215
|
+
daemon=True,
|
|
216
|
+
)
|
|
217
|
+
thread.start()
|
|
218
|
+
try:
|
|
219
|
+
yield callback
|
|
220
|
+
finally:
|
|
221
|
+
server.shutdown()
|
|
222
|
+
thread.join(timeout=3)
|
|
223
|
+
|
|
224
|
+
# Start callback server and open Playwright browser for authentication
|
|
225
|
+
port = urlparse(config.redirect_uri).port or 80
|
|
226
|
+
with callback_server(port):
|
|
227
|
+
async with async_playwright() as p:
|
|
228
|
+
browser = await p.chromium.launch(
|
|
229
|
+
channel=BROWSER_MAPPING[config.browser],
|
|
230
|
+
headless=False,
|
|
231
|
+
)
|
|
232
|
+
page = await browser.new_page()
|
|
233
|
+
await page.goto(
|
|
234
|
+
f"{config.authorization_url}"
|
|
235
|
+
f"?response_type=code"
|
|
236
|
+
f"&client_id={quote(config.client_id)}"
|
|
237
|
+
f"&redirect_uri={quote(config.redirect_uri)}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Wait about 2 minutes for the user to complete the login
|
|
241
|
+
try:
|
|
242
|
+
await asyncio.wait_for(received.wait(), timeout=120)
|
|
243
|
+
except TimeoutError:
|
|
244
|
+
raise AuthenticationFailed(
|
|
245
|
+
"Timed out waiting for the login to complete."
|
|
246
|
+
) from None
|
|
247
|
+
|
|
248
|
+
# Send callback code to token endpoint to receive access tokens
|
|
249
|
+
auth = httpx.BasicAuth(
|
|
250
|
+
username=config.client_id,
|
|
251
|
+
password=config.client_secret,
|
|
252
|
+
)
|
|
253
|
+
response = await session.post(
|
|
254
|
+
url=config.token_url,
|
|
255
|
+
data={
|
|
256
|
+
"grant_type": "authorization_code",
|
|
257
|
+
"code": callback["code"],
|
|
258
|
+
"redirect_uri": config.redirect_uri,
|
|
259
|
+
},
|
|
260
|
+
auth=auth,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Parse and validate response
|
|
264
|
+
try:
|
|
265
|
+
tokens = response.json()
|
|
266
|
+
except json.JSONDecodeError:
|
|
267
|
+
tokens = {}
|
|
268
|
+
if "access_token" not in tokens:
|
|
269
|
+
raise AuthenticationFailed(
|
|
270
|
+
f"Token endpoint returned an unexpected response "
|
|
271
|
+
f"[{response.status_code}]: {response.text}"
|
|
272
|
+
)
|
|
273
|
+
return tokens
|
datasphere_api/client.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from datasphere_api.auth import (
|
|
9
|
+
TokenStore,
|
|
10
|
+
authenticate_interactively,
|
|
11
|
+
refresh_tokens,
|
|
12
|
+
)
|
|
13
|
+
from datasphere_api.config import BROWSER_MAPPING, DatasphereConfig
|
|
14
|
+
from datasphere_api.exceptions import (
|
|
15
|
+
InvalidConfiguration,
|
|
16
|
+
MissingCredentials,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from datasphere_api.resources.analytical_models import AnalyticalModels
|
|
21
|
+
from datasphere_api.resources.remote_tables import RemoteTables
|
|
22
|
+
from datasphere_api.resources.task_chains import TaskChains
|
|
23
|
+
from datasphere_api.resources.views import Views
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DatasphereClient:
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: DatasphereConfig):
|
|
31
|
+
"""
|
|
32
|
+
Initializes the client and its httpx session. Doesn't perform any
|
|
33
|
+
file or network I/O.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
config (DatasphereConfig): Configuration with the URLs and
|
|
37
|
+
credentials of the tenant.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
MissingCredentials: If no client secret is configured.
|
|
41
|
+
InvalidConfiguration: If an unsupported browser is configured.
|
|
42
|
+
"""
|
|
43
|
+
# Validate configuration
|
|
44
|
+
if not config.client_secret:
|
|
45
|
+
raise MissingCredentials()
|
|
46
|
+
if config.browser not in BROWSER_MAPPING:
|
|
47
|
+
raise InvalidConfiguration(
|
|
48
|
+
f"Unsupported browser '{config.browser}'. Supported "
|
|
49
|
+
f"browsers are: {', '.join(BROWSER_MAPPING)}."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Initialize session and token store
|
|
53
|
+
self.config = config
|
|
54
|
+
self.session: httpx.AsyncClient = httpx.AsyncClient(
|
|
55
|
+
timeout=config.timeout,
|
|
56
|
+
follow_redirects=True,
|
|
57
|
+
)
|
|
58
|
+
self.token_store = TokenStore(config.session_file)
|
|
59
|
+
|
|
60
|
+
# Lazily created resource instances
|
|
61
|
+
self._analytical_models: AnalyticalModels | None = None
|
|
62
|
+
self._remote_tables: RemoteTables | None = None
|
|
63
|
+
self._task_chains: TaskChains | None = None
|
|
64
|
+
self._views: Views | None = None
|
|
65
|
+
|
|
66
|
+
async def login(self) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Authenticates the session against the Datasphere tenant. Refreshes
|
|
69
|
+
cached tokens from the token store if they exist. Falls back to
|
|
70
|
+
the interactive browser login if no tokens are cached or the
|
|
71
|
+
refresh fails.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
AuthenticationFailed: If the interactive login fails.
|
|
75
|
+
"""
|
|
76
|
+
# Set default headers
|
|
77
|
+
self.session.headers.update(
|
|
78
|
+
{
|
|
79
|
+
"User-Agent": (
|
|
80
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/"
|
|
81
|
+
"537.36 (KHTML, like Gecko) Chrome/138.0.0.0 "
|
|
82
|
+
"Safari/537.36 Edg/138.0.0.0"
|
|
83
|
+
),
|
|
84
|
+
"Accept": "text/plain, */*; q=0.01",
|
|
85
|
+
"Accept-Encoding": "gzip, deflate, zstd",
|
|
86
|
+
"Accept-Language": "en",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Try to refresh cached tokens
|
|
91
|
+
tokens = self.token_store.load()
|
|
92
|
+
if tokens is not None and "refresh_token" in tokens:
|
|
93
|
+
logger.info("Loading session tokens...")
|
|
94
|
+
new_tokens = await refresh_tokens(
|
|
95
|
+
config=self.config,
|
|
96
|
+
session=self.session,
|
|
97
|
+
refresh_token=tokens["refresh_token"],
|
|
98
|
+
)
|
|
99
|
+
if new_tokens is not None:
|
|
100
|
+
self._apply_tokens(new_tokens)
|
|
101
|
+
return
|
|
102
|
+
logger.warning("Starting a new login...")
|
|
103
|
+
self.token_store.delete()
|
|
104
|
+
else:
|
|
105
|
+
logger.debug("No session tokens found.")
|
|
106
|
+
|
|
107
|
+
# Start interactive login
|
|
108
|
+
logger.debug("Opening browser window to log in...")
|
|
109
|
+
tokens = await authenticate_interactively(
|
|
110
|
+
config=self.config,
|
|
111
|
+
session=self.session,
|
|
112
|
+
)
|
|
113
|
+
self._apply_tokens(tokens)
|
|
114
|
+
|
|
115
|
+
def _apply_tokens(self, tokens: dict) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Adds the access token to the session headers and persists the
|
|
118
|
+
tokens in the token store.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
tokens (dict): Tokens returned by the token endpoint.
|
|
122
|
+
"""
|
|
123
|
+
self.session.headers.update(
|
|
124
|
+
{"Authorization": f"Bearer {tokens['access_token']}"}
|
|
125
|
+
)
|
|
126
|
+
self.token_store.save(tokens)
|
|
127
|
+
|
|
128
|
+
async def aclose(self) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Closes the underlying httpx session.
|
|
131
|
+
"""
|
|
132
|
+
await self.session.aclose()
|
|
133
|
+
|
|
134
|
+
async def run_async_tasks(
|
|
135
|
+
self,
|
|
136
|
+
items: Iterable,
|
|
137
|
+
function: Callable,
|
|
138
|
+
thread_count: int = 1,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Executes the given function. 'Parallelizes' the tasks if the
|
|
142
|
+
thread count is greater than 1.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
items (Iterable): List of all arguments to be passed to the
|
|
146
|
+
function.
|
|
147
|
+
function (Callable): Function to be executed.
|
|
148
|
+
thread_count (int, optional): Amount of concurrent
|
|
149
|
+
asynchronous tasks.
|
|
150
|
+
Default is 1.
|
|
151
|
+
"""
|
|
152
|
+
if thread_count > 1:
|
|
153
|
+
semaphore = asyncio.Semaphore(thread_count)
|
|
154
|
+
tasks = []
|
|
155
|
+
for item in items:
|
|
156
|
+
|
|
157
|
+
async def process_item(item):
|
|
158
|
+
async with semaphore:
|
|
159
|
+
if isinstance(item, list | tuple):
|
|
160
|
+
await function(*item)
|
|
161
|
+
else:
|
|
162
|
+
await function(item)
|
|
163
|
+
|
|
164
|
+
task = asyncio.create_task(process_item(item))
|
|
165
|
+
tasks.append(task)
|
|
166
|
+
await asyncio.gather(*tasks)
|
|
167
|
+
|
|
168
|
+
else:
|
|
169
|
+
for item in items:
|
|
170
|
+
if isinstance(item, list | tuple):
|
|
171
|
+
await function(*item)
|
|
172
|
+
else:
|
|
173
|
+
await function(item)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def analytical_models(self) -> "AnalyticalModels":
|
|
177
|
+
"""
|
|
178
|
+
Returns:
|
|
179
|
+
AnalyticalModels: Resource for the analytical model APIs.
|
|
180
|
+
"""
|
|
181
|
+
if self._analytical_models is None:
|
|
182
|
+
from datasphere_api.resources.analytical_models import (
|
|
183
|
+
AnalyticalModels,
|
|
184
|
+
)
|
|
185
|
+
self._analytical_models = AnalyticalModels(self)
|
|
186
|
+
return self._analytical_models
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def remote_tables(self) -> "RemoteTables":
|
|
190
|
+
"""
|
|
191
|
+
Returns:
|
|
192
|
+
RemoteTables: Resource for the remote table APIs.
|
|
193
|
+
"""
|
|
194
|
+
if self._remote_tables is None:
|
|
195
|
+
from datasphere_api.resources.remote_tables import RemoteTables
|
|
196
|
+
self._remote_tables = RemoteTables(self)
|
|
197
|
+
return self._remote_tables
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def task_chains(self) -> "TaskChains":
|
|
201
|
+
"""
|
|
202
|
+
Returns:
|
|
203
|
+
TaskChains: Resource for the task chain APIs.
|
|
204
|
+
"""
|
|
205
|
+
if self._task_chains is None:
|
|
206
|
+
from datasphere_api.resources.task_chains import TaskChains
|
|
207
|
+
self._task_chains = TaskChains(self)
|
|
208
|
+
return self._task_chains
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def views(self) -> "Views":
|
|
212
|
+
"""
|
|
213
|
+
Returns:
|
|
214
|
+
Views: Resource for the view APIs.
|
|
215
|
+
"""
|
|
216
|
+
if self._views is None:
|
|
217
|
+
from datasphere_api.resources.views import Views
|
|
218
|
+
self._views = Views(self)
|
|
219
|
+
return self._views
|
datasphere_api/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
# Browsers supported for the interactive OAuth login
|
|
6
|
+
type Browser = Literal["CHROME", "EDGE"]
|
|
7
|
+
|
|
8
|
+
# Mapping of browser names to Playwright channel identifiers
|
|
9
|
+
BROWSER_MAPPING: dict[str, str] = {
|
|
10
|
+
"CHROME": "chrome",
|
|
11
|
+
"EDGE": "msedge",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class DatasphereConfig:
|
|
17
|
+
"""
|
|
18
|
+
Configuration for the Datasphere client. All URLs and credentials can
|
|
19
|
+
be found in the SAP Datasphere tenant under
|
|
20
|
+
System > Administration > App Integration.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
base_url (str): URL of the SAP Datasphere tenant.
|
|
24
|
+
authorization_url (str): OAuth authorization URL of the tenant.
|
|
25
|
+
token_url (str): OAuth token URL of the tenant.
|
|
26
|
+
client_id (str): OAuth client ID of an "Interactive Usage" client.
|
|
27
|
+
client_secret (str): OAuth client secret of the client.
|
|
28
|
+
browser (Browser, optional): Browser to use for the interactive
|
|
29
|
+
login. Has to be installed on the
|
|
30
|
+
system. Defaults to "CHROME".
|
|
31
|
+
session_file (Path | None, optional): Path of the file used to
|
|
32
|
+
cache the OAuth tokens.
|
|
33
|
+
Defaults to None, which
|
|
34
|
+
resolves to 'session.json'
|
|
35
|
+
in the user data directory
|
|
36
|
+
of 'Datasphere'.
|
|
37
|
+
redirect_uri (str, optional): Redirect URI configured for the
|
|
38
|
+
OAuth client. Defaults to
|
|
39
|
+
"http://localhost:8080".
|
|
40
|
+
timeout (float, optional): Timeout for HTTP requests in seconds.
|
|
41
|
+
Defaults to 60 seconds.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
base_url: str
|
|
45
|
+
authorization_url: str
|
|
46
|
+
token_url: str
|
|
47
|
+
client_id: str
|
|
48
|
+
client_secret: str
|
|
49
|
+
browser: Browser = "CHROME"
|
|
50
|
+
session_file: Path | None = None
|
|
51
|
+
redirect_uri: str = "http://localhost:8080"
|
|
52
|
+
timeout: float = 60.0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class DatasphereException(Exception):
|
|
2
|
+
"""Common base class for all exceptions raised by this library."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthenticationFailed(DatasphereException):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidConfiguration(DatasphereException):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MissingCredentials(DatasphereException):
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
super().__init__(
|
|
16
|
+
"Client secret not found. Please provide a client secret in "
|
|
17
|
+
"the configuration."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnexpectedResponse(DatasphereException):
|
|
22
|
+
pass
|