aamp-sdk 0.1.0__tar.gz

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,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: aamp-sdk
3
+ Version: 0.1.0
4
+ Summary: Portable Python SDK for AAMP
5
+ Author: AAMP
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Communications :: Email
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: websocket-client>=1.8.0
19
+
20
+ # aamp-sdk
21
+
22
+ Python SDK for AAMP.
23
+
24
+ This SDK now includes the same core runtime shape as the Node.js SDK:
25
+
26
+ - AAMP discovery and mailbox registration
27
+ - directory query and profile updates
28
+ - realtime stream create / append / get / close
29
+ - AAMP header builders and parsers
30
+ - SMTP sending for `task.dispatch`, `task.result`, `task.cancel`, `task.help_needed`, `task.stream.opened`, and `card.*`
31
+ - JMAP WebSocket push reception with polling fallback
32
+ - attachment blob download
33
+ - recent mailbox reconciliation as a safety net
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ python -m pip install aamp-sdk
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from aamp_sdk import AampClient
45
+
46
+ client = AampClient.from_mailbox_identity(
47
+ email="agent@example.com",
48
+ smtp_password="<smtp-password>",
49
+ base_url="https://meshmail.ai",
50
+ reject_unauthorized=False,
51
+ )
52
+
53
+ def on_dispatch(task: dict) -> None:
54
+ client.send_result(
55
+ to=task["from"],
56
+ task_id=task["taskId"],
57
+ status="completed",
58
+ output="done",
59
+ in_reply_to=task["messageId"],
60
+ )
61
+
62
+ client.on("task.dispatch", on_dispatch)
63
+ client.connect()
64
+
65
+ task_id, message_id = client.send_task(
66
+ to="dispatcher@example.com",
67
+ title="Prepare a summary",
68
+ body_text="Summarize the latest rollout status.",
69
+ priority="high",
70
+ )
71
+
72
+ stream = client.create_stream(task_id=task_id, peer_email="dispatcher@example.com")
73
+ client.send_stream_opened(
74
+ to="dispatcher@example.com",
75
+ task_id=task_id,
76
+ stream_id=stream["streamId"],
77
+ in_reply_to=message_id,
78
+ )
79
+ client.append_stream_event(
80
+ stream_id=stream["streamId"],
81
+ event_type="status",
82
+ payload={"stage": "running"},
83
+ )
84
+
85
+ client.send_result(
86
+ to="dispatcher@example.com",
87
+ task_id=task_id,
88
+ status="completed",
89
+ output="done",
90
+ in_reply_to=message_id,
91
+ )
92
+ ```
93
+
94
+ ## Parse AAMP headers
95
+
96
+ ```python
97
+ from aamp_sdk import parse_aamp_headers
98
+
99
+ message = parse_aamp_headers(
100
+ {
101
+ "from": "dispatcher@example.com",
102
+ "to": "agent@example.com",
103
+ "subject": "[AAMP Task] Review patch",
104
+ "messageId": "<msg-1@example.com>",
105
+ "bodyText": "Please review the patch.",
106
+ "headers": {
107
+ "X-AAMP-Intent": "task.dispatch",
108
+ "X-AAMP-TaskId": "task-123",
109
+ "X-AAMP-Priority": "high",
110
+ },
111
+ }
112
+ )
113
+ ```
114
+
115
+ ## Run tests
116
+
117
+ ```bash
118
+ cd packages/sdk-python
119
+ python -m unittest discover -s tests
120
+ ```
@@ -0,0 +1,101 @@
1
+ # aamp-sdk
2
+
3
+ Python SDK for AAMP.
4
+
5
+ This SDK now includes the same core runtime shape as the Node.js SDK:
6
+
7
+ - AAMP discovery and mailbox registration
8
+ - directory query and profile updates
9
+ - realtime stream create / append / get / close
10
+ - AAMP header builders and parsers
11
+ - SMTP sending for `task.dispatch`, `task.result`, `task.cancel`, `task.help_needed`, `task.stream.opened`, and `card.*`
12
+ - JMAP WebSocket push reception with polling fallback
13
+ - attachment blob download
14
+ - recent mailbox reconciliation as a safety net
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ python -m pip install aamp-sdk
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from aamp_sdk import AampClient
26
+
27
+ client = AampClient.from_mailbox_identity(
28
+ email="agent@example.com",
29
+ smtp_password="<smtp-password>",
30
+ base_url="https://meshmail.ai",
31
+ reject_unauthorized=False,
32
+ )
33
+
34
+ def on_dispatch(task: dict) -> None:
35
+ client.send_result(
36
+ to=task["from"],
37
+ task_id=task["taskId"],
38
+ status="completed",
39
+ output="done",
40
+ in_reply_to=task["messageId"],
41
+ )
42
+
43
+ client.on("task.dispatch", on_dispatch)
44
+ client.connect()
45
+
46
+ task_id, message_id = client.send_task(
47
+ to="dispatcher@example.com",
48
+ title="Prepare a summary",
49
+ body_text="Summarize the latest rollout status.",
50
+ priority="high",
51
+ )
52
+
53
+ stream = client.create_stream(task_id=task_id, peer_email="dispatcher@example.com")
54
+ client.send_stream_opened(
55
+ to="dispatcher@example.com",
56
+ task_id=task_id,
57
+ stream_id=stream["streamId"],
58
+ in_reply_to=message_id,
59
+ )
60
+ client.append_stream_event(
61
+ stream_id=stream["streamId"],
62
+ event_type="status",
63
+ payload={"stage": "running"},
64
+ )
65
+
66
+ client.send_result(
67
+ to="dispatcher@example.com",
68
+ task_id=task_id,
69
+ status="completed",
70
+ output="done",
71
+ in_reply_to=message_id,
72
+ )
73
+ ```
74
+
75
+ ## Parse AAMP headers
76
+
77
+ ```python
78
+ from aamp_sdk import parse_aamp_headers
79
+
80
+ message = parse_aamp_headers(
81
+ {
82
+ "from": "dispatcher@example.com",
83
+ "to": "agent@example.com",
84
+ "subject": "[AAMP Task] Review patch",
85
+ "messageId": "<msg-1@example.com>",
86
+ "bodyText": "Please review the patch.",
87
+ "headers": {
88
+ "X-AAMP-Intent": "task.dispatch",
89
+ "X-AAMP-TaskId": "task-123",
90
+ "X-AAMP-Priority": "high",
91
+ },
92
+ }
93
+ )
94
+ ```
95
+
96
+ ## Run tests
97
+
98
+ ```bash
99
+ cd packages/sdk-python
100
+ python -m unittest discover -s tests
101
+ ```
@@ -0,0 +1,45 @@
1
+ """Python SDK for portable AAMP integrations."""
2
+
3
+ from .client import AampClient
4
+ from .events import TinyEmitter
5
+ from .jmap_push import JmapPushClient
6
+ from .protocol import (
7
+ AAMP_HEADER,
8
+ AAMP_PROTOCOL_VERSION,
9
+ build_ack_headers,
10
+ build_cancel_headers,
11
+ build_card_query_headers,
12
+ build_card_response_headers,
13
+ build_dispatch_headers,
14
+ build_help_headers,
15
+ build_result_headers,
16
+ build_stream_opened_headers,
17
+ normalize_headers,
18
+ parse_aamp_headers,
19
+ parse_dispatch_context_header,
20
+ serialize_dispatch_context_header,
21
+ )
22
+ from .smtp import Attachment, SmtpSender, derive_mailbox_service_defaults
23
+
24
+ __all__ = [
25
+ "AampClient",
26
+ "AAMP_HEADER",
27
+ "AAMP_PROTOCOL_VERSION",
28
+ "Attachment",
29
+ "JmapPushClient",
30
+ "SmtpSender",
31
+ "TinyEmitter",
32
+ "build_ack_headers",
33
+ "build_cancel_headers",
34
+ "build_card_query_headers",
35
+ "build_card_response_headers",
36
+ "build_dispatch_headers",
37
+ "build_help_headers",
38
+ "build_result_headers",
39
+ "build_stream_opened_headers",
40
+ "derive_mailbox_service_defaults",
41
+ "normalize_headers",
42
+ "parse_aamp_headers",
43
+ "parse_dispatch_context_header",
44
+ "serialize_dispatch_context_header",
45
+ ]
@@ -0,0 +1,371 @@
1
+ """Portable Python client for AAMP service APIs and message sending."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import ssl
8
+ from typing import Any
9
+ from urllib.parse import urlencode, urljoin
10
+ from urllib.request import Request, urlopen
11
+
12
+ from .events import TinyEmitter
13
+ from .jmap_push import JmapPushClient
14
+ from .smtp import SmtpSender, derive_mailbox_service_defaults
15
+
16
+ DEFAULT_HTTP_TIMEOUT_SECS = 30
17
+
18
+
19
+ def _ssl_context(reject_unauthorized: bool) -> ssl.SSLContext:
20
+ return ssl.create_default_context() if reject_unauthorized else ssl._create_unverified_context()
21
+
22
+
23
+ class AampClient(TinyEmitter):
24
+ def __init__(
25
+ self,
26
+ *,
27
+ email: str,
28
+ mailbox_token: str,
29
+ base_url: str,
30
+ smtp_password: str,
31
+ http_send_base_url: str | None = None,
32
+ smtp_host: str | None = None,
33
+ smtp_port: int = 587,
34
+ reconnect_interval: float = 5.0,
35
+ reject_unauthorized: bool = True,
36
+ ) -> None:
37
+ super().__init__()
38
+ self.email = email
39
+ self.mailbox_token = mailbox_token
40
+ self.base_url = base_url
41
+ self.reject_unauthorized = reject_unauthorized
42
+
43
+ derived = derive_mailbox_service_defaults(email, base_url)
44
+ self.smtp_sender = SmtpSender(
45
+ host=smtp_host or str(derived["smtp_host"]),
46
+ port=smtp_port,
47
+ user=email,
48
+ password=smtp_password,
49
+ http_base_url=http_send_base_url or base_url,
50
+ auth_token=mailbox_token,
51
+ reject_unauthorized=reject_unauthorized,
52
+ )
53
+ decoded = base64.b64decode(mailbox_token.encode("ascii")).decode("utf-8")
54
+ _mailbox_email, _sep, password = decoded.partition(":")
55
+ if not _sep or not password:
56
+ raise RuntimeError("Invalid mailboxToken format: expected base64(email:password)")
57
+ self.jmap_client = JmapPushClient(
58
+ email=email,
59
+ password=password,
60
+ jmap_url=base_url,
61
+ reconnect_interval=reconnect_interval,
62
+ reject_unauthorized=reject_unauthorized,
63
+ )
64
+ for event_name in [
65
+ "task.dispatch",
66
+ "task.cancel",
67
+ "task.result",
68
+ "task.help_needed",
69
+ "task.ack",
70
+ "task.stream.opened",
71
+ "card.query",
72
+ "card.response",
73
+ "reply",
74
+ "connected",
75
+ "disconnected",
76
+ "error",
77
+ ]:
78
+ self.jmap_client.on(event_name, self._forward_event(event_name))
79
+ self.jmap_client.on("_autoAck", self._handle_auto_ack)
80
+
81
+ def _forward_event(self, event_name: str) -> Any:
82
+ def handler(*args: Any) -> None:
83
+ self.emit(event_name, *args)
84
+
85
+ return handler
86
+
87
+ def _handle_auto_ack(self, payload: dict[str, Any]) -> None:
88
+ try:
89
+ self.smtp_sender.send_ack(
90
+ to=str(payload["to"]),
91
+ task_id=str(payload["taskId"]),
92
+ in_reply_to=str(payload["messageId"]),
93
+ )
94
+ except Exception as err:
95
+ self.emit("error", RuntimeError(f"[AAMP] Failed to send ACK for task {payload.get('taskId')}: {err}"))
96
+
97
+ @classmethod
98
+ def from_mailbox_identity(
99
+ cls,
100
+ *,
101
+ email: str,
102
+ smtp_password: str,
103
+ base_url: str | None = None,
104
+ smtp_port: int = 587,
105
+ reconnect_interval: float = 5.0,
106
+ reject_unauthorized: bool = True,
107
+ ) -> "AampClient":
108
+ derived = derive_mailbox_service_defaults(email, base_url)
109
+ token = base64.b64encode(f"{email}:{smtp_password}".encode("utf-8")).decode("ascii")
110
+ return cls(
111
+ email=email,
112
+ mailbox_token=token,
113
+ base_url=str(derived["http_base_url"] or f"https://{email.split('@', 1)[1]}"),
114
+ smtp_password=smtp_password,
115
+ smtp_host=str(derived["smtp_host"]),
116
+ smtp_port=smtp_port,
117
+ reconnect_interval=reconnect_interval,
118
+ reject_unauthorized=reject_unauthorized,
119
+ )
120
+
121
+ @staticmethod
122
+ def _request_json(
123
+ url: str,
124
+ *,
125
+ method: str = "GET",
126
+ body: Any | None = None,
127
+ headers: dict[str, str] | None = None,
128
+ reject_unauthorized: bool = True,
129
+ ) -> Any:
130
+ data = None
131
+ request_headers = {"Accept": "application/json", **(headers or {})}
132
+ if body is not None:
133
+ request_headers["Content-Type"] = "application/json"
134
+ data = json.dumps(body).encode("utf-8")
135
+ request = Request(url, method=method, data=data, headers=request_headers)
136
+ with urlopen(
137
+ request,
138
+ context=_ssl_context(reject_unauthorized),
139
+ timeout=DEFAULT_HTTP_TIMEOUT_SECS,
140
+ ) as response:
141
+ return json.loads(response.read().decode("utf-8"))
142
+
143
+ @classmethod
144
+ def discover_aamp_service(cls, aamp_host: str, *, reject_unauthorized: bool = True) -> dict[str, Any]:
145
+ base = aamp_host.rstrip("/")
146
+ discovery = cls._request_json(
147
+ f"{base}/.well-known/aamp",
148
+ reject_unauthorized=reject_unauthorized,
149
+ )
150
+ if not discovery.get("api", {}).get("url"):
151
+ raise RuntimeError("AAMP discovery did not return api.url")
152
+ return discovery
153
+
154
+ @classmethod
155
+ def _call_discovered_api(
156
+ cls,
157
+ base: str,
158
+ *,
159
+ action: str,
160
+ method: str = "GET",
161
+ query: dict[str, Any] | None = None,
162
+ body: Any | None = None,
163
+ auth_token: str | None = None,
164
+ reject_unauthorized: bool = True,
165
+ ) -> Any:
166
+ discovery = cls.discover_aamp_service(base, reject_unauthorized=reject_unauthorized)
167
+ api_url = urljoin(f"{base.rstrip('/')}/", discovery["api"]["url"])
168
+ params = {"action": action}
169
+ for key, value in (query or {}).items():
170
+ if value is not None:
171
+ params[key] = str(value).lower() if isinstance(value, bool) else str(value)
172
+ url = f"{api_url}?{urlencode(params)}"
173
+ headers = {"Authorization": f"Basic {auth_token}"} if auth_token else None
174
+ return cls._request_json(
175
+ url,
176
+ method=method,
177
+ body=body,
178
+ headers=headers,
179
+ reject_unauthorized=reject_unauthorized,
180
+ )
181
+
182
+ @classmethod
183
+ def register_mailbox(
184
+ cls,
185
+ *,
186
+ aamp_host: str,
187
+ slug: str,
188
+ description: str | None = None,
189
+ reject_unauthorized: bool = True,
190
+ ) -> dict[str, str]:
191
+ base = aamp_host.rstrip("/")
192
+ registration = cls._call_discovered_api(
193
+ base,
194
+ action="aamp.mailbox.register",
195
+ method="POST",
196
+ body={"slug": slug, "description": description},
197
+ reject_unauthorized=reject_unauthorized,
198
+ )
199
+ code = registration.get("registrationCode")
200
+ if not code:
201
+ raise RuntimeError("Mailbox registration succeeded but no registrationCode was returned")
202
+
203
+ credentials = cls._call_discovered_api(
204
+ base,
205
+ action="aamp.mailbox.credentials",
206
+ query={"code": code},
207
+ reject_unauthorized=reject_unauthorized,
208
+ )
209
+ email = credentials.get("email")
210
+ mailbox_token = credentials.get("mailbox", {}).get("token")
211
+ smtp_password = credentials.get("smtp", {}).get("password")
212
+ if not email or not mailbox_token or not smtp_password:
213
+ raise RuntimeError("Mailbox credential exchange returned an incomplete identity payload")
214
+
215
+ return {
216
+ "email": email,
217
+ "mailboxToken": mailbox_token,
218
+ "smtpPassword": smtp_password,
219
+ "baseUrl": base,
220
+ }
221
+
222
+ def send_task(self, **kwargs: Any) -> tuple[str, str]:
223
+ return self.smtp_sender.send_task(**kwargs)
224
+
225
+ def connect(self) -> None:
226
+ self.jmap_client.start()
227
+
228
+ def disconnect(self) -> None:
229
+ self.jmap_client.stop()
230
+
231
+ def is_connected(self) -> bool:
232
+ return self.jmap_client.is_connected()
233
+
234
+ def is_using_polling_fallback(self) -> bool:
235
+ return self.jmap_client.is_using_polling_fallback()
236
+
237
+ def send_result(self, **kwargs: Any) -> None:
238
+ self.smtp_sender.send_result(**kwargs)
239
+
240
+ def send_help(self, **kwargs: Any) -> None:
241
+ self.smtp_sender.send_help(**kwargs)
242
+
243
+ def send_cancel(self, **kwargs: Any) -> None:
244
+ self.smtp_sender.send_cancel(**kwargs)
245
+
246
+ def send_stream_opened(self, **kwargs: Any) -> None:
247
+ self.smtp_sender.send_stream_opened(**kwargs)
248
+
249
+ def send_card_query(self, **kwargs: Any) -> tuple[str, str]:
250
+ return self.smtp_sender.send_card_query(**kwargs)
251
+
252
+ def send_card_response(self, **kwargs: Any) -> None:
253
+ self.smtp_sender.send_card_response(**kwargs)
254
+
255
+ def download_blob(self, blob_id: str, filename: str | None = None) -> bytes:
256
+ return self.jmap_client.download_blob(blob_id, filename)
257
+
258
+ def reconcile_recent_emails(self, limit: int = 20, *, include_historical: bool = False) -> int:
259
+ return self.jmap_client.reconcile_recent_emails(limit, include_historical=include_historical)
260
+
261
+ def update_directory_profile(
262
+ self,
263
+ *,
264
+ summary: str | None = None,
265
+ card_text: str | None = None,
266
+ ) -> dict[str, Any]:
267
+ response = self._call_discovered_api(
268
+ self.base_url,
269
+ action="aamp.directory.upsert",
270
+ method="POST",
271
+ auth_token=self.mailbox_token,
272
+ body={"summary": summary, "cardText": card_text},
273
+ reject_unauthorized=self.reject_unauthorized,
274
+ )
275
+ return dict(response.get("profile", {}))
276
+
277
+ def list_directory(
278
+ self,
279
+ *,
280
+ scope: str | None = None,
281
+ include_self: bool | None = None,
282
+ limit: int | None = None,
283
+ ) -> list[dict[str, Any]]:
284
+ response = self._call_discovered_api(
285
+ self.base_url,
286
+ action="aamp.directory.list",
287
+ auth_token=self.mailbox_token,
288
+ query={"scope": scope, "includeSelf": include_self, "limit": limit},
289
+ reject_unauthorized=self.reject_unauthorized,
290
+ )
291
+ return list(response.get("agents", []))
292
+
293
+ def search_directory(
294
+ self,
295
+ *,
296
+ query: str,
297
+ scope: str | None = None,
298
+ include_self: bool | None = None,
299
+ limit: int | None = None,
300
+ ) -> list[dict[str, Any]]:
301
+ response = self._call_discovered_api(
302
+ self.base_url,
303
+ action="aamp.directory.search",
304
+ auth_token=self.mailbox_token,
305
+ query={
306
+ "q": query,
307
+ "scope": scope,
308
+ "includeSelf": include_self,
309
+ "limit": limit,
310
+ },
311
+ reject_unauthorized=self.reject_unauthorized,
312
+ )
313
+ return list(response.get("agents", []))
314
+
315
+ def _resolve_stream_capability(self) -> dict[str, Any]:
316
+ discovery = self.discover_aamp_service(
317
+ self.base_url,
318
+ reject_unauthorized=self.reject_unauthorized,
319
+ )
320
+ stream = discovery.get("capabilities", {}).get("stream")
321
+ if not stream or not stream.get("transport"):
322
+ raise RuntimeError("AAMP stream capability is not available on this service")
323
+ return dict(stream)
324
+
325
+ def create_stream(self, *, task_id: str, peer_email: str) -> dict[str, Any]:
326
+ stream = self._resolve_stream_capability()
327
+ return self._call_discovered_api(
328
+ self.base_url,
329
+ action=stream.get("createAction", "aamp.stream.create"),
330
+ method="POST",
331
+ auth_token=self.mailbox_token,
332
+ body={"taskId": task_id, "peerEmail": peer_email},
333
+ reject_unauthorized=self.reject_unauthorized,
334
+ )
335
+
336
+ def append_stream_event(self, *, stream_id: str, event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
337
+ stream = self._resolve_stream_capability()
338
+ return self._call_discovered_api(
339
+ self.base_url,
340
+ action=stream.get("appendAction", "aamp.stream.append"),
341
+ method="POST",
342
+ auth_token=self.mailbox_token,
343
+ body={"streamId": stream_id, "type": event_type, "payload": payload},
344
+ reject_unauthorized=self.reject_unauthorized,
345
+ )
346
+
347
+ def close_stream(self, *, stream_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
348
+ stream = self._resolve_stream_capability()
349
+ return self._call_discovered_api(
350
+ self.base_url,
351
+ action=stream.get("closeAction", "aamp.stream.close"),
352
+ method="POST",
353
+ auth_token=self.mailbox_token,
354
+ body={"streamId": stream_id, "payload": payload or {}},
355
+ reject_unauthorized=self.reject_unauthorized,
356
+ )
357
+
358
+ def get_task_stream(
359
+ self,
360
+ *,
361
+ task_id: str | None = None,
362
+ stream_id: str | None = None,
363
+ ) -> dict[str, Any]:
364
+ stream = self._resolve_stream_capability()
365
+ return self._call_discovered_api(
366
+ self.base_url,
367
+ action=stream.get("getAction", "aamp.stream.get"),
368
+ auth_token=self.mailbox_token,
369
+ query={"taskId": task_id, "streamId": stream_id},
370
+ reject_unauthorized=self.reject_unauthorized,
371
+ )
@@ -0,0 +1,49 @@
1
+ """A tiny event emitter used by the Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections import defaultdict
7
+ from typing import Any, Callable
8
+
9
+
10
+ Listener = Callable[..., Any]
11
+
12
+
13
+ class TinyEmitter:
14
+ def __init__(self) -> None:
15
+ self._listeners: dict[str, list[Listener]] = defaultdict(list)
16
+ self._once_wrappers: dict[tuple[str, Listener], Listener] = {}
17
+ self._lock = threading.RLock()
18
+
19
+ def on(self, event: str, listener: Listener) -> "TinyEmitter":
20
+ with self._lock:
21
+ self._listeners[event].append(listener)
22
+ return self
23
+
24
+ def once(self, event: str, listener: Listener) -> "TinyEmitter":
25
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
26
+ self.off(event, listener)
27
+ return listener(*args, **kwargs)
28
+
29
+ with self._lock:
30
+ self._once_wrappers[(event, listener)] = wrapped
31
+ self._listeners[event].append(wrapped)
32
+ return self
33
+
34
+ def off(self, event: str, listener: Listener) -> "TinyEmitter":
35
+ with self._lock:
36
+ wrapped = self._once_wrappers.pop((event, listener), None)
37
+ target = wrapped or listener
38
+ bucket = self._listeners.get(event, [])
39
+ self._listeners[event] = [item for item in bucket if item is not target]
40
+ if not self._listeners[event]:
41
+ self._listeners.pop(event, None)
42
+ return self
43
+
44
+ def emit(self, event: str, *args: Any, **kwargs: Any) -> bool:
45
+ with self._lock:
46
+ listeners = list(self._listeners.get(event, []))
47
+ for listener in listeners:
48
+ listener(*args, **kwargs)
49
+ return bool(listeners)