diaspora-event-sdk 0.4.0__py3-none-any.whl → 0.4.3__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.
@@ -9,7 +9,10 @@ from diaspora_event_sdk.sdk.client import Client
9
9
  from diaspora_event_sdk.sdk.kafka_client import (
10
10
  KafkaProducer,
11
11
  KafkaConsumer,
12
- block_until_ready,
13
12
  )
14
13
 
15
- __all__ = ("Client", "KafkaProducer", "KafkaConsumer", "block_until_ready")
14
+ __all__ = (
15
+ "Client",
16
+ "KafkaProducer",
17
+ "KafkaConsumer",
18
+ )
@@ -13,9 +13,7 @@ def get_web_service_url(envname: Union[str, None] = None) -> str:
13
13
  env = envname or _get_envname()
14
14
  urls = {
15
15
  "production": "https://diaspora-web-service.qpp943wkvr7b2.us-east-1.cs.amazonlightsail.com/",
16
- "dev": "https://diaspora-web-service.qpp943wkvr7b2.us-east-1.cs.amazonlightsail.com/",
17
16
  "local": "http://localhost:8000",
18
- "legacy": "https://diaspora-web-service-dev.ml22sevubfnks.us-east-1.cs.amazonlightsail.com",
19
17
  }
20
18
 
21
19
  return urls.get(env, urls["production"])
@@ -1,4 +1,3 @@
1
- import json
2
1
  from typing import Optional
3
2
 
4
3
  from diaspora_event_sdk.sdk.login_manager import (
@@ -7,7 +6,7 @@ from diaspora_event_sdk.sdk.login_manager import (
7
6
  requires_login,
8
7
  )
9
8
 
10
- from ._environments import DIASPORA_RESOURCE_SERVER, get_web_service_url
9
+ from ._environments import get_web_service_url
11
10
 
12
11
 
13
12
  class Client:
@@ -32,213 +31,83 @@ class Client:
32
31
  )
33
32
  self.auth_client = self.login_manager.get_auth_client()
34
33
  self.subject_openid = self.auth_client.userinfo()["sub"]
34
+ self.namespace = f"ns-{self.subject_openid.replace('-', '')[-12:]}"
35
35
 
36
36
  def logout(self):
37
37
  """Remove credentials from your local system"""
38
38
  self.login_manager.logout()
39
39
 
40
40
  @requires_login
41
- def create_key(self):
42
- """
43
- Revokes existing keys, generates a new key, and updates the token storage with the newly created key and the Diaspora endpoint.
44
- """
45
- resp = self.web_client.create_key(self.subject_openid)
46
- if resp["status"] == "error":
47
- raise Exception("should not happen")
48
- tokens = self.login_manager._token_storage.get_token_data(
49
- DIASPORA_RESOURCE_SERVER
50
- )
51
- tokens["access_key"] = resp["access_key"]
52
- tokens["secret_key"] = resp["secret_key"]
53
- tokens["endpoint"] = resp["endpoint"]
54
- with self.login_manager._access_lock:
55
- self.login_manager._token_storage._connection.executemany(
56
- "REPLACE INTO token_storage(namespace, resource_server, token_data_json) "
57
- "VALUES(?, ?, ?)",
58
- [
59
- (
60
- self.login_manager._token_storage.namespace,
61
- DIASPORA_RESOURCE_SERVER,
62
- json.dumps(tokens),
63
- )
64
- ],
65
- )
66
- self.login_manager._token_storage._connection.commit()
67
- return {
68
- "access_key": tokens["access_key"],
69
- "secret_key": tokens["secret_key"],
70
- "endpoint": tokens["endpoint"],
71
- }
72
-
73
- @requires_login
74
- def retrieve_key(self):
75
- """
76
- Retrieves the key from local token storage, and calls create_key() if the local key is not found.
77
- """
78
- tokens = self.login_manager._token_storage.get_token_data(
79
- DIASPORA_RESOURCE_SERVER
80
- )
81
- if (
82
- tokens is None
83
- or "endpoint" not in tokens
84
- or "access_key" not in tokens
85
- or "secret_key" not in tokens
86
- ):
87
- return self.create_key()
88
- else:
89
- return {
90
- "access_key": tokens["access_key"],
91
- "secret_key": tokens["secret_key"],
92
- "endpoint": tokens["endpoint"],
93
- }
94
-
95
- @requires_login
96
- def put_secret_key(self, access_key, secret_key, endpoint):
97
- tokens = self.login_manager._token_storage.get_token_data(
98
- DIASPORA_RESOURCE_SERVER
99
- )
100
- tokens["access_key"] = access_key
101
- tokens["secret_key"] = secret_key
102
- tokens["endpoint"] = endpoint
103
- with self.login_manager._access_lock:
104
- self.login_manager._token_storage._connection.executemany(
105
- "REPLACE INTO token_storage(namespace, resource_server, token_data_json) "
106
- "VALUES(?, ?, ?)",
107
- [
108
- (
109
- self.login_manager._token_storage.namespace,
110
- DIASPORA_RESOURCE_SERVER,
111
- json.dumps(tokens),
112
- )
113
- ],
114
- )
115
- self.login_manager._token_storage._connection.commit()
116
-
117
- @requires_login
118
- def list_topics(self):
119
- """
120
- Returns a list of topics currently registered under the user's account.
121
- """
122
- return self.web_client.list_topics(self.subject_openid)
123
-
124
- @requires_login
125
- def register_topic(self, topic):
126
- """
127
- Registers a new topic the user's account with permissions to read, write, and describe the topic.
128
- """
129
- return self.web_client.register_topic(self.subject_openid, topic, "register")
130
-
131
- @requires_login
132
- def unregister_topic(self, topic):
133
- """
134
- Unregisters a topic from a user's account, but all existing events within the topic are unaffected.
135
- """
136
- return self.web_client.register_topic(self.subject_openid, topic, "unregister")
137
-
138
- @requires_login
139
- def get_topic_configs(self, topic):
41
+ def create_user(self):
140
42
  """
141
- Retrieves the current configurations for a registered topic.
43
+ Create an IAM user with policy and namespace for the current user (POST /api/v3/user).
44
+ Returns status, message, subject, and namespace.
142
45
  """
143
- return self.web_client.get_topic_configs(self.subject_openid, topic)
46
+ resp = self.web_client.create_user(self.subject_openid)
47
+ return resp.data if hasattr(resp, "data") else resp
144
48
 
145
49
  @requires_login
146
- def update_topic_configs(self, topic, configs):
50
+ def delete_user(self):
147
51
  """
148
- Updates the configurations for a registered topic.
52
+ Delete the IAM user and all associated resources for the current user (DELETE /api/v3/user).
53
+ Returns status and message.
149
54
  """
150
- return self.web_client.update_topic_configs(self.subject_openid, topic, configs)
55
+ resp = self.web_client.delete_user(self.subject_openid)
56
+ return resp.data if hasattr(resp, "data") else resp
151
57
 
152
58
  @requires_login
153
- def update_topic_partitions(self, topic, new_partitions):
154
- """
155
- Increases the number of partitions for a given topic to the specified new partition count.
156
- """
157
- return self.web_client.update_topic_partitions(
158
- self.subject_openid, topic, new_partitions
159
- )
160
-
161
- @requires_login
162
- def reset_topic(self, topic):
163
- """
164
- Deletes and recreates the topic, removing all messages and restoring the topic to the default configurations while user access is not affected.
165
- """
166
- return self.web_client.reset_topic(self.subject_openid, topic)
167
-
168
- @requires_login
169
- def grant_user_access(self, topic, user):
170
- """
171
- Authorizes another user to access a registered topic under the invoker's account.
172
- """
173
- return self.web_client.grant_user_access(
174
- self.subject_openid, topic, user, "grant"
175
- )
176
-
177
- @requires_login
178
- def revoke_user_access(self, topic, user):
59
+ def create_key(self):
179
60
  """
180
- Removes access permissions for another user from a registered topic under the invoker's account.
61
+ Create a new access key for the current user (POST /api/v3/key).
62
+ This will replace any existing access key (force refresh).
63
+ Returns status, message, access_key, secret_key, and create_date.
181
64
  """
182
- return self.web_client.grant_user_access(
183
- self.subject_openid, topic, user, "revoke"
184
- )
65
+ resp = self.web_client.create_key(self.subject_openid)
66
+ return resp.data if hasattr(resp, "data") else resp
185
67
 
186
68
  @requires_login
187
- def list_topic_users(self, topic):
69
+ def delete_key(self):
188
70
  """
189
- Returns a list of users that have access to the topic.
71
+ Delete access keys from IAM and DynamoDB for the current user (DELETE /api/v3/key).
72
+ Returns status and message.
190
73
  """
191
- return self.web_client.list_topic_users(self.subject_openid, topic)
74
+ resp = self.web_client.delete_key(self.subject_openid)
75
+ return resp.data if hasattr(resp, "data") else resp
192
76
 
193
77
  @requires_login
194
- def list_triggers(self):
78
+ def list_namespaces(self):
195
79
  """
196
- Retrieves a list of triggers associated created under the user's account, showing each trigger's configurations and UUID.
80
+ List all namespaces owned by the current user and their topics (GET /api/v3/namespace).
81
+ Returns status, message, and namespaces dict (namespace -> list of topics).
197
82
  """
198
- return self.web_client.list_triggers(self.subject_openid)
83
+ resp = self.web_client.list_namespaces(self.subject_openid)
84
+ return resp.data if hasattr(resp, "data") else resp
199
85
 
200
86
  @requires_login
201
- def create_trigger(self, topic, function, function_configs, trigger_configs):
87
+ def create_topic(self, topic: str):
202
88
  """
203
- Creates a new trigger under the user's account with specific function and invocation configurations.
89
+ Create a topic under the user's default namespace (POST /api/v3/{namespace}/{topic}).
90
+ Returns status, message, and topics list.
204
91
  """
205
- return self.web_client.create_trigger(
206
- self.subject_openid,
207
- topic,
208
- function,
209
- "create",
210
- function_configs,
211
- trigger_configs,
212
- )
92
+ resp = self.web_client.create_topic(self.subject_openid, self.namespace, topic)
93
+ return resp.data if hasattr(resp, "data") else resp
213
94
 
214
95
  @requires_login
215
- def delete_trigger(self, topic, function):
96
+ def delete_topic(self, topic: str):
216
97
  """
217
- Deletes a trigger and related AWS resources, while the associated topic remains unaffected.
98
+ Delete a topic from the user's default namespace (DELETE /api/v3/{namespace}/{topic}).
99
+ Returns status, message, and topics list.
218
100
  """
219
- return self.web_client.create_trigger(
220
- self.subject_openid, topic, function, "delete", {}, {}
221
- )
101
+ resp = self.web_client.delete_topic(self.subject_openid, self.namespace, topic)
102
+ return resp.data if hasattr(resp, "data") else resp
222
103
 
223
104
  @requires_login
224
- def update_trigger(self, trigger_uuid, trigger_configs):
105
+ def recreate_topic(self, topic: str):
225
106
  """
226
- Updates invocation configurations of an existing trigger, identified by its unique trigger UUID.
107
+ Recreate a topic in the user's default namespace by deleting and recreating it (PUT /api/v3/{namespace}/{topic}/recreate).
108
+ Returns status and message.
227
109
  """
228
- return self.web_client.update_trigger(
229
- self.subject_openid, trigger_uuid, trigger_configs
110
+ resp = self.web_client.recreate_topic(
111
+ self.subject_openid, self.namespace, topic
230
112
  )
231
-
232
- @requires_login
233
- def list_log_streams(self, trigger):
234
- """
235
- List log streams of a trigger under the user's account
236
- """
237
- return self.web_client.list_log_streams(self.subject_openid, trigger)
238
-
239
- @requires_login
240
- def get_log_events(self, trigger, stream):
241
- """
242
- Get events in a particular log stream of a trigger under the user's account
243
- """
244
- return self.web_client.get_log_events(self.subject_openid, trigger, stream)
113
+ return resp.data if hasattr(resp, "data") else resp
@@ -1,24 +1,37 @@
1
1
  import json
2
- from typing import Dict, Any
3
- import warnings
2
+ import logging
4
3
  import time
4
+ import uuid
5
+ import warnings
6
+ from typing import Any, Dict
5
7
 
6
- from .client import Client
7
8
  from .aws_iam_msk import generate_auth_token
9
+ from .client import Client
10
+
11
+ # File-level logger
12
+ logger = logging.getLogger(__name__)
8
13
 
9
14
  # If kafka-python is not installed, Kafka functionality is not available through diaspora-event-sdk.
10
15
  kafka_available = True
11
16
  try:
12
- from kafka import KafkaProducer as KProd # type: ignore[import,import-not-found]
13
- from kafka import KafkaConsumer as KCons # type: ignore[import,import-not-found]
14
17
  import os
15
18
 
16
- class MSKTokenProvider:
19
+ from kafka import KafkaConsumer as KCons # type: ignore[import,import-not-found]
20
+ from kafka import KafkaProducer as KProd # type: ignore[import,import-not-found]
21
+ from kafka.errors import KafkaTimeoutError, TopicAuthorizationFailedError # type: ignore[import,import-not-found]
22
+ from kafka.sasl.oauth import (
23
+ AbstractTokenProvider, # type: ignore[import,import-not-found]
24
+ )
25
+
26
+ class MSKTokenProvider(AbstractTokenProvider):
17
27
  def token(self):
18
28
  token, _ = generate_auth_token("us-east-1")
19
29
  return token
20
30
  except Exception:
21
31
  kafka_available = False
32
+ # Fallback if kafka-python is not available
33
+ TopicAuthorizationFailedError = Exception
34
+ KafkaTimeoutError = Exception
22
35
 
23
36
 
24
37
  def get_diaspora_config(extra_configs: Dict[str, Any] = {}) -> Dict[str, Any]:
@@ -28,15 +41,11 @@ def get_diaspora_config(extra_configs: Dict[str, Any] = {}) -> Dict[str, Any]:
28
41
  """
29
42
 
30
43
  try:
31
- if (
32
- "OCTOPUS_AWS_ACCESS_KEY_ID" not in os.environ
33
- and "OCTOPUS_AWS_SECRET_ACCESS_KEY" not in os.environ
34
- and "OCTOPUS_BOOTSTRAP_SERVERS" not in os.environ
35
- ):
36
- keys = Client().retrieve_key()
37
- os.environ["OCTOPUS_AWS_ACCESS_KEY_ID"] = keys["access_key"]
38
- os.environ["OCTOPUS_AWS_SECRET_ACCESS_KEY"] = keys["secret_key"]
39
- os.environ["OCTOPUS_BOOTSTRAP_SERVERS"] = keys["endpoint"]
44
+ client = Client()
45
+ keys = client.create_key() # create or retrieve key
46
+ os.environ["OCTOPUS_AWS_ACCESS_KEY_ID"] = keys["access_key"]
47
+ os.environ["OCTOPUS_AWS_SECRET_ACCESS_KEY"] = keys["secret_key"]
48
+ os.environ["OCTOPUS_BOOTSTRAP_SERVERS"] = keys["endpoint"]
40
49
 
41
50
  except Exception as e:
42
51
  raise RuntimeError("Failed to retrieve Kafka keys") from e
@@ -45,7 +54,7 @@ def get_diaspora_config(extra_configs: Dict[str, Any] = {}) -> Dict[str, Any]:
45
54
  "bootstrap_servers": os.environ["OCTOPUS_BOOTSTRAP_SERVERS"],
46
55
  "security_protocol": "SASL_SSL",
47
56
  "sasl_mechanism": "OAUTHBEARER",
48
- "api_version": (3, 5, 1),
57
+ "api_version": (3, 8, 1),
49
58
  "sasl_oauth_token_provider": MSKTokenProvider(),
50
59
  }
51
60
  conf.update(extra_configs)
@@ -55,15 +64,35 @@ def get_diaspora_config(extra_configs: Dict[str, Any] = {}) -> Dict[str, Any]:
55
64
  if kafka_available:
56
65
 
57
66
  class KafkaProducer(KProd):
58
- def __init__(self, **configs):
67
+ """
68
+ Wrapper around KProd that:
69
+ - Requires at least one topic
70
+ - Sets a default JSON serializer
71
+ - Does NOT block until topics have partition metadata
72
+ """
73
+
74
+ def __init__(self, *topics, **configs):
75
+ if not topics:
76
+ raise ValueError("KafkaProducer requires at least one topic")
77
+ self.topics = topics
78
+
59
79
  configs.setdefault(
60
- "value_serializer", lambda v: json.dumps(v).encode("utf-8")
80
+ "value_serializer",
81
+ lambda v: json.dumps(v).encode("utf-8"),
61
82
  )
83
+
62
84
  super().__init__(**get_diaspora_config(configs))
85
+ # Note: We do NOT block on metadata here
63
86
 
64
87
  class KafkaConsumer(KCons):
65
88
  def __init__(self, *topics, **configs):
89
+ if not topics:
90
+ raise ValueError("KafkaConsumer requires at least one topic")
91
+ self.topics = topics
92
+
66
93
  super().__init__(*topics, **get_diaspora_config(configs))
94
+ # Note: We do NOT block on metadata here
95
+
67
96
 
68
97
  else:
69
98
  # Create dummy classes that issue a warning when instantiated
@@ -82,56 +111,72 @@ else:
82
111
  )
83
112
 
84
113
 
85
- # TODO: mypy diaspora_event_sdk/sdk/kafka_client.py --disallow-untyped-defs
86
- def block_until_ready(max_minutes=5):
87
- """
88
- Test Kafka producer and consumer connections.
89
- By default, this method blocks for five minutes before giving up.
90
- It returns a boolean that indicates whether the connections can be successfully established.
114
+ def reliable_client_creation() -> str:
91
115
  """
116
+ Reliably create a client and test topic operations with retry logic.
92
117
 
93
- def producer_connection_test(result):
94
- try:
95
- producer = KafkaProducer(max_block_ms=10 * 1000)
96
- future = producer.send(
97
- topic="__connection_test",
98
- value={"message": "Synchronous message from Diaspora SDK"},
99
- )
100
- result["producer_connection_test"] = future.get(timeout=10)
101
- except Exception as e:
102
- raise e
103
- print(e)
104
-
105
- def consumer_connection_test(result):
118
+ Returns:
119
+ str: The full topic name in format "namespace.topic-name"
120
+ """
121
+ attempt = 0
122
+ while True:
123
+ attempt += 1
124
+ if attempt > 1:
125
+ time.sleep(5)
126
+
127
+ topic_name = None
128
+ kafka_topic = None
129
+ client = None
106
130
  try:
107
- consumer = KafkaConsumer(
108
- "__connection_test",
109
- consumer_timeout_ms=10 * 1000,
110
- auto_offset_reset="earliest",
111
- )
112
- for msg in consumer:
113
- result["consumer_connection_test"] = msg
114
- break
131
+ client = Client()
132
+ key_result = client.create_key()
133
+
134
+ # If status is not success, throw exception
135
+ if key_result.get("status") != "success":
136
+ raise RuntimeError(
137
+ f"Failed to create key: {key_result.get('message', 'Unknown error')}"
138
+ )
139
+
140
+ # If key is fresh (just created), wait for IAM policy to propagate
141
+ if key_result.get("fresh", False):
142
+ time.sleep(8)
143
+
144
+ topic_name = f"topic-{str(uuid.uuid4())[:5]}"
145
+ kafka_topic = f"{client.namespace}.{topic_name}"
146
+
147
+ # If status is not success, throw exception
148
+ topic_result = client.create_topic(topic_name)
149
+ if topic_result.get("status") != "success":
150
+ raise RuntimeError(
151
+ f"Failed to create topic: {topic_result.get('message', 'Unknown error')}"
152
+ )
153
+ time.sleep(3) # Wait after topic creation before produce
154
+
155
+ producer = KafkaProducer(kafka_topic)
156
+ for i in range(3):
157
+ future = producer.send(
158
+ kafka_topic, {"message_id": i + 1, "content": f"Message {i + 1}"}
159
+ )
160
+ future.get(timeout=30)
161
+ producer.close()
162
+ time.sleep(2) # Wait for the produced messages to be consumed
163
+
164
+ consumer = KafkaConsumer(kafka_topic, auto_offset_reset="earliest")
165
+ consumer.poll(timeout_ms=10000)
166
+ consumer.close()
167
+
168
+ client.delete_topic(topic_name)
169
+ return kafka_topic
115
170
  except Exception as e:
116
- raise e
117
- print(e)
118
-
119
- result, retry_count = {}, 0
120
- start_time = time.time()
121
- while len(result) < 2: # two tests
122
- if retry_count > 0:
123
- print(
124
- f"Block until connected or timed out ({max_minutes} minutes)... retry count:",
125
- retry_count,
126
- ", time passed:",
127
- int(time.time() - start_time),
128
- "seconds",
129
- )
130
- producer_connection_test(result)
131
- consumer_connection_test(result)
132
- retry_count += 1
133
- elapsed_time = time.time() - start_time
134
- if elapsed_time >= max_minutes * 60:
135
- print("Time limit exceeded. Exiting loop.")
136
- return False
137
- return True
171
+ logger.info(f"Error in attempt {attempt}: {type(e).__name__}: {str(e)}")
172
+ if client:
173
+ try:
174
+ if topic_name:
175
+ client.delete_topic(topic_name)
176
+ except Exception:
177
+ pass
178
+ try:
179
+ client.delete_user()
180
+ except Exception:
181
+ pass
182
+ continue
@@ -19,6 +19,61 @@ from .login_flow import do_link_auth_flow
19
19
  log = logging.getLogger(__name__)
20
20
 
21
21
 
22
+ class FilteredClientCredentialsAuthorizer(globus_sdk.ClientCredentialsAuthorizer):
23
+ """
24
+ A custom ClientCredentialsAuthorizer that filters token responses to only
25
+ include tokens for a specific resource server.
26
+
27
+ This is needed when client credentials return tokens for multiple resource
28
+ servers, but ClientCredentialsAuthorizer expects exactly one token.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ confidential_client: globus_sdk.ConfidentialAppAuthClient,
34
+ scopes: list[str],
35
+ *,
36
+ resource_server: str,
37
+ access_token: str | None = None,
38
+ expires_at: int | None = None,
39
+ on_refresh: t.Callable[[globus_sdk.OAuthTokenResponse], None] | None = None,
40
+ ) -> None:
41
+ self._target_resource_server = resource_server
42
+ # Store the original on_refresh callback
43
+ self._original_on_refresh = on_refresh
44
+ super().__init__(
45
+ confidential_client=confidential_client,
46
+ scopes=scopes,
47
+ access_token=access_token,
48
+ expires_at=expires_at,
49
+ on_refresh=self._filtered_on_refresh,
50
+ )
51
+
52
+ def _extract_token_data(
53
+ self, res: globus_sdk.OAuthClientCredentialsResponse
54
+ ) -> dict[str, t.Any]:
55
+ """
56
+ Extract token data, filtering to only the target resource server.
57
+ """
58
+ token_data = res.by_resource_server
59
+ if self._target_resource_server in token_data:
60
+ # Return only the token for the target resource server
61
+ return token_data[self._target_resource_server]
62
+ else:
63
+ raise ValueError(
64
+ f"Token response does not contain token for {self._target_resource_server}"
65
+ )
66
+
67
+ def _filtered_on_refresh(
68
+ self, token_response: globus_sdk.OAuthTokenResponse
69
+ ) -> None:
70
+ """
71
+ Call the original on_refresh callback with the filtered token response.
72
+ """
73
+ if self._original_on_refresh:
74
+ self._original_on_refresh(token_response)
75
+
76
+
22
77
  def _get_diaspora_all_scope() -> str:
23
78
  return os.getenv(
24
79
  "DIASPORA_SCOPE",
@@ -153,9 +208,13 @@ class LoginManager:
153
208
  expires_at = tokens["expires_at_seconds"]
154
209
 
155
210
  with self._access_lock:
156
- return globus_sdk.ClientCredentialsAuthorizer(
211
+ # Use a custom authorizer that filters token responses to only
212
+ # the requested resource server, handling cases where client
213
+ # credentials return tokens for multiple resource servers
214
+ return FilteredClientCredentialsAuthorizer(
157
215
  confidential_client=get_client_login(),
158
216
  scopes=scopes,
217
+ resource_server=resource_server,
159
218
  access_token=access_token,
160
219
  expires_at=expires_at,
161
220
  on_refresh=self._token_storage.on_refresh,