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.
@@ -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
@@ -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
@@ -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