dataspace-sdk 0.4.2__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,18 @@
1
+ """DataSpace Python SDK for programmatic access to DataSpace resources."""
2
+
3
+ from dataspace_sdk.__version__ import __version__
4
+ from dataspace_sdk.client import DataSpaceClient
5
+ from dataspace_sdk.exceptions import (
6
+ DataSpaceAPIError,
7
+ DataSpaceAuthError,
8
+ DataSpaceNotFoundError,
9
+ DataSpaceValidationError,
10
+ )
11
+
12
+ __all__ = [
13
+ "DataSpaceClient",
14
+ "DataSpaceAPIError",
15
+ "DataSpaceAuthError",
16
+ "DataSpaceNotFoundError",
17
+ "DataSpaceValidationError",
18
+ ]
@@ -0,0 +1,3 @@
1
+ """Version information for DataSpace SDK."""
2
+
3
+ __version__ = "0.4.2"
dataspace_sdk/auth.py ADDED
@@ -0,0 +1,470 @@
1
+ """Authentication module for DataSpace SDK."""
2
+
3
+ import time
4
+ from typing import Any, Dict, Optional
5
+
6
+ import requests
7
+
8
+ from dataspace_sdk.exceptions import DataSpaceAuthError
9
+
10
+
11
+ class AuthClient:
12
+ """Handles authentication with DataSpace API."""
13
+
14
+ def __init__(
15
+ self,
16
+ base_url: str,
17
+ keycloak_url: Optional[str] = None,
18
+ keycloak_realm: Optional[str] = None,
19
+ keycloak_client_id: Optional[str] = None,
20
+ keycloak_client_secret: Optional[str] = None,
21
+ ):
22
+ """
23
+ Initialize the authentication client.
24
+
25
+ Args:
26
+ base_url: Base URL of the DataSpace API
27
+ keycloak_url: Keycloak server URL (e.g., "https://opub-kc.civicdatalab.in")
28
+ keycloak_realm: Keycloak realm name (e.g., "DataSpace")
29
+ keycloak_client_id: Keycloak client ID (e.g., "dataspace")
30
+ keycloak_client_secret: Optional client secret for confidential clients
31
+ """
32
+ self.base_url = base_url.rstrip("/")
33
+ self.keycloak_url = keycloak_url.rstrip("/") if keycloak_url else None
34
+ self.keycloak_realm = keycloak_realm
35
+ self.keycloak_client_id = keycloak_client_id
36
+ self.keycloak_client_secret = keycloak_client_secret
37
+
38
+ # Session state
39
+ self.access_token: Optional[str] = None
40
+ self.refresh_token: Optional[str] = None
41
+ self.keycloak_access_token: Optional[str] = None
42
+ self.keycloak_refresh_token: Optional[str] = None
43
+ self.token_expires_at: Optional[float] = None
44
+ self.user_info: Optional[Dict] = None
45
+
46
+ # Stored credentials for auto-relogin
47
+ self._username: Optional[str] = None
48
+ self._password: Optional[str] = None
49
+
50
+ def login(self, username: str, password: str) -> Dict[str, Any]:
51
+ """
52
+ Login using username and password via Keycloak.
53
+
54
+ Args:
55
+ username: User's username or email
56
+ password: User's password
57
+
58
+ Returns:
59
+ Dictionary containing user info and tokens
60
+
61
+ Raises:
62
+ DataSpaceAuthError: If authentication fails
63
+ """
64
+ if not all([self.keycloak_url, self.keycloak_realm, self.keycloak_client_id]):
65
+ raise DataSpaceAuthError(
66
+ "Keycloak configuration missing. Please provide keycloak_url, "
67
+ "keycloak_realm, and keycloak_client_id when initializing the client."
68
+ )
69
+
70
+ # Store credentials for auto-relogin
71
+ self._username = username
72
+ self._password = password
73
+
74
+ # Get Keycloak token
75
+ keycloak_token = self._get_keycloak_token(username, password)
76
+
77
+ # Login to DataSpace backend
78
+ return self._login_with_keycloak_token(keycloak_token)
79
+
80
+ def login_as_service_account(self) -> Dict[str, Any]:
81
+ """
82
+ Login using client credentials (service account).
83
+
84
+ This method authenticates the client itself (not a user) using
85
+ the client_id and client_secret. Requires the Keycloak client
86
+ to have "Service Accounts Enabled".
87
+
88
+ Returns:
89
+ Dictionary containing user info and tokens
90
+
91
+ Raises:
92
+ DataSpaceAuthError: If authentication fails
93
+ """
94
+ if not all(
95
+ [
96
+ self.keycloak_url,
97
+ self.keycloak_realm,
98
+ self.keycloak_client_id,
99
+ self.keycloak_client_secret,
100
+ ]
101
+ ):
102
+ raise DataSpaceAuthError(
103
+ "Service account authentication requires keycloak_url, "
104
+ "keycloak_realm, keycloak_client_id, and keycloak_client_secret."
105
+ )
106
+
107
+ # Get Keycloak token using client credentials
108
+ keycloak_token = self._get_service_account_token()
109
+
110
+ # Login to DataSpace backend
111
+ return self._login_with_keycloak_token(keycloak_token)
112
+
113
+ def _get_keycloak_token(self, username: str, password: str) -> str:
114
+ """
115
+ Get Keycloak access token using username and password.
116
+
117
+ Args:
118
+ username: User's username or email
119
+ password: User's password
120
+
121
+ Returns:
122
+ Keycloak access token
123
+
124
+ Raises:
125
+ DataSpaceAuthError: If authentication fails
126
+ """
127
+ token_url = (
128
+ f"{self.keycloak_url}/auth/realms/{self.keycloak_realm}/"
129
+ f"protocol/openid-connect/token"
130
+ )
131
+
132
+ data = {
133
+ "grant_type": "password",
134
+ "client_id": self.keycloak_client_id,
135
+ "username": username,
136
+ "password": password,
137
+ }
138
+
139
+ if self.keycloak_client_secret:
140
+ data["client_secret"] = self.keycloak_client_secret
141
+
142
+ try:
143
+ response = requests.post(
144
+ token_url,
145
+ data=data,
146
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
147
+ )
148
+
149
+ if response.status_code == 200:
150
+ token_data = response.json()
151
+ self.keycloak_access_token = token_data.get("access_token")
152
+ self.keycloak_refresh_token = token_data.get("refresh_token")
153
+
154
+ # Calculate token expiration time
155
+ expires_in = token_data.get("expires_in", 300)
156
+ self.token_expires_at = time.time() + expires_in
157
+
158
+ if not self.keycloak_access_token:
159
+ raise DataSpaceAuthError("No access token in Keycloak response")
160
+
161
+ return self.keycloak_access_token
162
+ else:
163
+ error_data = response.json()
164
+ error_msg = error_data.get(
165
+ "error_description",
166
+ error_data.get("error", "Keycloak authentication failed"),
167
+ )
168
+ raise DataSpaceAuthError(
169
+ f"Keycloak login failed: {error_msg}",
170
+ status_code=response.status_code,
171
+ response=error_data,
172
+ )
173
+ except requests.RequestException as e:
174
+ raise DataSpaceAuthError(f"Network error during Keycloak authentication: {str(e)}")
175
+
176
+ def _get_service_account_token(self) -> str:
177
+ """
178
+ Get Keycloak access token using client credentials (service account).
179
+
180
+ Returns:
181
+ Keycloak access token
182
+
183
+ Raises:
184
+ DataSpaceAuthError: If authentication fails
185
+ """
186
+ token_url = (
187
+ f"{self.keycloak_url}/auth/realms/{self.keycloak_realm}/"
188
+ f"protocol/openid-connect/token"
189
+ )
190
+
191
+ data = {
192
+ "grant_type": "client_credentials",
193
+ "client_id": self.keycloak_client_id,
194
+ "client_secret": self.keycloak_client_secret,
195
+ }
196
+
197
+ try:
198
+ response = requests.post(
199
+ token_url,
200
+ data=data,
201
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
202
+ )
203
+
204
+ if response.status_code == 200:
205
+ token_data = response.json()
206
+ self.keycloak_access_token = token_data.get("access_token")
207
+ self.keycloak_refresh_token = token_data.get("refresh_token")
208
+
209
+ # Calculate token expiration time
210
+ expires_in = token_data.get("expires_in", 300)
211
+ self.token_expires_at = time.time() + expires_in
212
+
213
+ if not self.keycloak_access_token:
214
+ raise DataSpaceAuthError("No access token in Keycloak response")
215
+
216
+ return self.keycloak_access_token
217
+ else:
218
+ error_data = response.json()
219
+ error_msg = error_data.get(
220
+ "error_description",
221
+ error_data.get("error", "Service account authentication failed"),
222
+ )
223
+ raise DataSpaceAuthError(
224
+ f"Service account login failed: {error_msg}. "
225
+ f"Ensure 'Service Accounts Enabled' is ON in Keycloak client settings.",
226
+ status_code=response.status_code,
227
+ response=error_data,
228
+ )
229
+ except requests.RequestException as e:
230
+ raise DataSpaceAuthError(
231
+ f"Network error during service account authentication: {str(e)}"
232
+ )
233
+
234
+ def _refresh_keycloak_token(self) -> str:
235
+ """
236
+ Refresh Keycloak access token using refresh token.
237
+
238
+ Returns:
239
+ New Keycloak access token
240
+
241
+ Raises:
242
+ DataSpaceAuthError: If token refresh fails
243
+ """
244
+ if not self.keycloak_refresh_token:
245
+ # If no refresh token, try to relogin with stored credentials
246
+ if self._username and self._password:
247
+ return self._get_keycloak_token(self._username, self._password)
248
+ raise DataSpaceAuthError("No refresh token or credentials available")
249
+
250
+ token_url = (
251
+ f"{self.keycloak_url}/auth/realms/{self.keycloak_realm}/"
252
+ f"protocol/openid-connect/token"
253
+ )
254
+
255
+ data = {
256
+ "grant_type": "refresh_token",
257
+ "client_id": self.keycloak_client_id,
258
+ "refresh_token": self.keycloak_refresh_token,
259
+ }
260
+
261
+ if self.keycloak_client_secret:
262
+ data["client_secret"] = self.keycloak_client_secret
263
+
264
+ try:
265
+ response = requests.post(
266
+ token_url,
267
+ data=data,
268
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
269
+ )
270
+
271
+ if response.status_code == 200:
272
+ token_data = response.json()
273
+ self.keycloak_access_token = token_data.get("access_token")
274
+ self.keycloak_refresh_token = token_data.get("refresh_token")
275
+
276
+ expires_in = token_data.get("expires_in", 300)
277
+ self.token_expires_at = time.time() + expires_in
278
+
279
+ if not self.keycloak_access_token:
280
+ raise DataSpaceAuthError("No access token in refresh response")
281
+
282
+ return self.keycloak_access_token
283
+ else:
284
+ # Refresh failed, try to relogin with stored credentials
285
+ if self._username and self._password:
286
+ return self._get_keycloak_token(self._username, self._password)
287
+ raise DataSpaceAuthError("Keycloak token refresh failed")
288
+ except requests.RequestException as e:
289
+ # Network error, try to relogin with stored credentials
290
+ if self._username and self._password:
291
+ return self._get_keycloak_token(self._username, self._password)
292
+ raise DataSpaceAuthError(f"Network error during token refresh: {str(e)}")
293
+
294
+ def _ensure_valid_keycloak_token(self) -> str:
295
+ """
296
+ Ensure we have a valid Keycloak token, refreshing if necessary.
297
+
298
+ Returns:
299
+ Valid Keycloak access token
300
+
301
+ Raises:
302
+ DataSpaceAuthError: If unable to get valid token
303
+ """
304
+ # Check if token is expired or about to expire (within 30 seconds)
305
+ if (
306
+ not self.keycloak_access_token
307
+ or not self.token_expires_at
308
+ or time.time() >= (self.token_expires_at - 30)
309
+ ):
310
+ # Token expired or about to expire, refresh it
311
+ if self.keycloak_refresh_token or (self._username and self._password):
312
+ return self._refresh_keycloak_token()
313
+ raise DataSpaceAuthError("No valid token or credentials available")
314
+
315
+ return self.keycloak_access_token
316
+
317
+ def _login_with_keycloak_token(self, keycloak_token: str) -> Dict[str, Any]:
318
+ """
319
+ Login using a Keycloak token.
320
+
321
+ Args:
322
+ keycloak_token: Valid Keycloak access token
323
+
324
+ Returns:
325
+ Dictionary containing user info and tokens
326
+
327
+ Raises:
328
+ DataSpaceAuthError: If authentication fails
329
+ """
330
+ url = f"{self.base_url}/api/auth/keycloak/login/"
331
+
332
+ try:
333
+ response = requests.post(
334
+ url,
335
+ json={"token": keycloak_token},
336
+ headers={"Content-Type": "application/json"},
337
+ )
338
+
339
+ if response.status_code == 200:
340
+ data: Dict[str, Any] = response.json()
341
+ self.access_token = data.get("access")
342
+ self.refresh_token = data.get("refresh")
343
+ self.user_info = data.get("user")
344
+ return data
345
+ else:
346
+ error_msg = response.json().get("error", "Authentication failed")
347
+ raise DataSpaceAuthError(
348
+ error_msg,
349
+ status_code=response.status_code,
350
+ response=response.json(),
351
+ )
352
+ except requests.RequestException as e:
353
+ raise DataSpaceAuthError(f"Network error during authentication: {str(e)}")
354
+
355
+ def refresh_access_token(self) -> str:
356
+ """
357
+ Refresh the access token using the refresh token.
358
+
359
+ Returns:
360
+ New access token
361
+
362
+ Raises:
363
+ DataSpaceAuthError: If token refresh fails
364
+ """
365
+ if not self.refresh_token:
366
+ raise DataSpaceAuthError("No refresh token available")
367
+
368
+ url = f"{self.base_url}/api/auth/token/refresh/"
369
+
370
+ try:
371
+ response = requests.post(
372
+ url,
373
+ json={"refresh": self.refresh_token},
374
+ headers={"Content-Type": "application/json"},
375
+ )
376
+
377
+ if response.status_code == 200:
378
+ data = response.json()
379
+ self.access_token = data.get("access")
380
+ if self.access_token is None:
381
+ raise DataSpaceAuthError("Token refresh returned no access token")
382
+ return self.access_token
383
+ else:
384
+ raise DataSpaceAuthError(
385
+ "Token refresh failed",
386
+ status_code=response.status_code,
387
+ response=response.json(),
388
+ )
389
+ except requests.RequestException as e:
390
+ raise DataSpaceAuthError(f"Network error during token refresh: {str(e)}")
391
+
392
+ def get_user_info(self) -> Dict[str, Any]:
393
+ """
394
+ Get current user information.
395
+
396
+ Returns:
397
+ Dictionary containing user information
398
+
399
+ Raises:
400
+ DataSpaceAuthError: If request fails
401
+ """
402
+ if not self.access_token:
403
+ raise DataSpaceAuthError("Not authenticated. Please login first.")
404
+
405
+ url = f"{self.base_url}/api/auth/user/info/"
406
+
407
+ try:
408
+ response = requests.get(
409
+ url,
410
+ headers=self._get_auth_headers(),
411
+ )
412
+
413
+ if response.status_code == 200:
414
+ user_info: Dict[str, Any] = response.json()
415
+ self.user_info = user_info
416
+ return self.user_info
417
+ else:
418
+ raise DataSpaceAuthError(
419
+ "Failed to get user info",
420
+ status_code=response.status_code,
421
+ response=response.json(),
422
+ )
423
+ except requests.RequestException as e:
424
+ raise DataSpaceAuthError(f"Network error getting user info: {str(e)}")
425
+
426
+ def _get_auth_headers(self) -> Dict[str, str]:
427
+ """Get headers with authentication token."""
428
+ if not self.access_token:
429
+ return {}
430
+ return {"Authorization": f"Bearer {self.access_token}"}
431
+
432
+ def is_authenticated(self) -> bool:
433
+ """Check if the client is authenticated."""
434
+ return self.access_token is not None
435
+
436
+ def ensure_authenticated(self) -> None:
437
+ """
438
+ Ensure the client is authenticated, attempting auto-relogin if needed.
439
+
440
+ Raises:
441
+ DataSpaceAuthError: If unable to authenticate
442
+ """
443
+ if not self.is_authenticated():
444
+ # Try to relogin with stored credentials
445
+ if self._username and self._password:
446
+ self.login(self._username, self._password)
447
+ else:
448
+ raise DataSpaceAuthError("Not authenticated. Please call login() first.")
449
+
450
+ def get_valid_token(self) -> str:
451
+ """
452
+ Get a valid access token, refreshing if necessary.
453
+
454
+ Returns:
455
+ Valid access token
456
+
457
+ Raises:
458
+ DataSpaceAuthError: If unable to get valid token
459
+ """
460
+ # First ensure we have a valid Keycloak token
461
+ if self.keycloak_url and self.keycloak_realm:
462
+ keycloak_token = self._ensure_valid_keycloak_token()
463
+ # Re-login to backend with fresh Keycloak token if needed
464
+ if not self.access_token:
465
+ self._login_with_keycloak_token(keycloak_token)
466
+
467
+ if not self.access_token:
468
+ raise DataSpaceAuthError("No access token available")
469
+
470
+ return self.access_token
dataspace_sdk/base.py ADDED
@@ -0,0 +1,160 @@
1
+ """Base client for making API requests."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ import requests
6
+
7
+ from dataspace_sdk.exceptions import (
8
+ DataSpaceAPIError,
9
+ DataSpaceAuthError,
10
+ DataSpaceNotFoundError,
11
+ DataSpaceValidationError,
12
+ )
13
+
14
+
15
+ class BaseAPIClient:
16
+ """Base client for making API requests to DataSpace."""
17
+
18
+ def __init__(self, base_url: str, auth_client: Any = None):
19
+ """
20
+ Initialize the base API client.
21
+
22
+ Args:
23
+ base_url: Base URL of the DataSpace API
24
+ auth_client: Authentication client instance
25
+ """
26
+ self.base_url = base_url.rstrip("/")
27
+ self.auth_client = auth_client
28
+
29
+ def _get_headers(self, additional_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
30
+ """
31
+ Get request headers including authentication.
32
+
33
+ Args:
34
+ additional_headers: Additional headers to include
35
+
36
+ Returns:
37
+ Dictionary of headers
38
+ """
39
+ headers = {"Content-Type": "application/json"}
40
+
41
+ if self.auth_client and self.auth_client.is_authenticated():
42
+ headers["Authorization"] = f"Bearer {self.auth_client.access_token}"
43
+
44
+ if additional_headers:
45
+ headers.update(additional_headers)
46
+
47
+ return headers
48
+
49
+ def _make_request(
50
+ self,
51
+ method: str,
52
+ endpoint: str,
53
+ params: Optional[Dict[str, Any]] = None,
54
+ data: Optional[Dict[str, Any]] = None,
55
+ json_data: Optional[Dict[str, Any]] = None,
56
+ headers: Optional[Dict[str, str]] = None,
57
+ ) -> Dict[str, Any]:
58
+ """
59
+ Make an HTTP request to the API.
60
+
61
+ Args:
62
+ method: HTTP method (GET, POST, etc.)
63
+ endpoint: API endpoint
64
+ params: Query parameters
65
+ data: Form data
66
+ json_data: JSON data
67
+ headers: Additional headers
68
+
69
+ Returns:
70
+ Response data as dictionary
71
+
72
+ Raises:
73
+ DataSpaceAPIError: For API errors
74
+ DataSpaceAuthError: For authentication errors
75
+ DataSpaceNotFoundError: For 404 errors
76
+ DataSpaceValidationError: For validation errors
77
+ """
78
+ url = f"{self.base_url}{endpoint}"
79
+ request_headers = self._get_headers(headers)
80
+
81
+ try:
82
+ response = requests.request(
83
+ method=method,
84
+ url=url,
85
+ params=params,
86
+ data=data,
87
+ json=json_data,
88
+ headers=request_headers,
89
+ )
90
+
91
+ # Handle different status codes
92
+ if response.status_code == 200 or response.status_code == 201:
93
+ result: Dict[str, Any] = response.json() if response.content else {}
94
+ return result
95
+ elif response.status_code == 204:
96
+ return {}
97
+ elif response.status_code == 401:
98
+ raise DataSpaceAuthError(
99
+ "Authentication required or token expired",
100
+ status_code=response.status_code,
101
+ response=response.json() if response.content else {},
102
+ )
103
+ elif response.status_code == 404:
104
+ raise DataSpaceNotFoundError(
105
+ "Resource not found",
106
+ status_code=response.status_code,
107
+ response=response.json() if response.content else {},
108
+ )
109
+ elif response.status_code == 400:
110
+ raise DataSpaceValidationError(
111
+ "Validation error",
112
+ status_code=response.status_code,
113
+ response=response.json() if response.content else {},
114
+ )
115
+ else:
116
+ raise DataSpaceAPIError(
117
+ f"API request failed with status {response.status_code}",
118
+ status_code=response.status_code,
119
+ response=response.json() if response.content else {},
120
+ )
121
+
122
+ except requests.RequestException as e:
123
+ raise DataSpaceAPIError(f"Network error: {str(e)}")
124
+
125
+ def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
126
+ """Make a GET request."""
127
+ return self._make_request("GET", endpoint, params=params)
128
+
129
+ def post(
130
+ self,
131
+ endpoint: str,
132
+ data: Optional[Dict[str, Any]] = None,
133
+ json_data: Optional[Dict[str, Any]] = None,
134
+ ) -> Dict[str, Any]:
135
+ """Make a POST request."""
136
+ return self._make_request("POST", endpoint, data=data, json_data=json_data)
137
+
138
+ def put(
139
+ self,
140
+ endpoint: str,
141
+ data: Optional[Dict[str, Any]] = None,
142
+ json_data: Optional[Dict[str, Any]] = None,
143
+ ) -> Dict[str, Any]:
144
+ """Make a PUT request."""
145
+ return self._make_request("PUT", endpoint, data=data, json_data=json_data)
146
+
147
+ def patch(
148
+ self,
149
+ endpoint: str,
150
+ data: Optional[Dict[str, Any]] = None,
151
+ json_data: Optional[Dict[str, Any]] = None,
152
+ ) -> Dict[str, Any]:
153
+ """Make a PATCH request."""
154
+ return self._make_request("PATCH", endpoint, data=data, json_data=json_data)
155
+
156
+ def delete(self, endpoint: str) -> Dict[str, Any]:
157
+ """Make a DELETE request."""
158
+ response = self._make_request("DELETE", endpoint)
159
+ result: Dict[str, Any] = response if isinstance(response, dict) else {}
160
+ return result