pattern-agentic-messaging 0.8.1__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,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:raw.githubusercontent.com)"
5
+ ],
6
+ "deny": [],
7
+ "ask": []
8
+ }
9
+ }
@@ -0,0 +1,3 @@
1
+ *~
2
+ **/#*
3
+ __pycache__
@@ -0,0 +1,20 @@
1
+ Copyright © 2025 Pattern Agentic
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ “Software”), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: pattern_agentic_messaging
3
+ Version: 0.8.1
4
+ Summary: SLIM-powered messaging
5
+ Author-email: Amos Joshua <amos@patternagentic.ai>
6
+ License-File: LICENSE.md
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: slim-bindings<0.7.0,>=0.6.3
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Pattern Agentic Messaging
15
+
16
+ An async SLIM wrapper with a FastAPI-like interface
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install pattern_agentic_messaging
22
+ ```
23
+
24
+ ## Usage
25
+
26
+
27
+ ### Server
28
+
29
+ Route messages to decorated methods based on a _discriminator_ field
30
+ like `type`:
31
+
32
+ ```python
33
+ from pattern_agentic_messaging import PASlimApp, PASlimConfig
34
+
35
+ config = PASlimConfig(
36
+ local_name="org/ns/server/instance1",
37
+ endpoint="https://slim.example.com",
38
+ auth_secret="shared-secret",
39
+ message_discriminator="type"
40
+ )
41
+
42
+ app = PASlimApp(config)
43
+ agent = None
44
+
45
+ @app.on_session_connect
46
+ async def on_connect(session):
47
+ agent = await create_agent(...)
48
+ session.context = {
49
+ "agent": agent
50
+ }
51
+
52
+ # expects {"type": "prompt", "question": "...."}
53
+ @app.on_message('prompt')
54
+ async def handle_prompt(session, msg):
55
+ agent = session.context.get("agent")
56
+ response = await agent.ask(msg["question"])
57
+ await session.send({"type": "response", "answer": response})
58
+
59
+ # expects {"type": "status"}
60
+ @app.on_message('status')
61
+ async def handle_status(session, msg):
62
+ await session.send({"type": "status", "value": "ready"})
63
+
64
+ @app.on_message
65
+ async def handle_other(session, msg):
66
+ await session.send({"error": f"Unknown message type: {msg.get('type')}"})
67
+
68
+ app.run()
69
+ ```
70
+
71
+ Use `PASlimConfigGroup` to create a group channel.
72
+
73
+
74
+ ### Client
75
+
76
+ Connect to a specific peer:
77
+
78
+ ```python
79
+ from pattern_agentic_messaging import PASlimApp, PASlimConfig
80
+
81
+ config = PASlimConfig(
82
+ local_name="org/ns/client/instance1",
83
+ endpoint="https://slim.example.com",
84
+ auth_secret="shared-secret"
85
+ )
86
+
87
+ async with PASlimApp(config) as app:
88
+ async with await app.connect("org/ns/server/instance1") as session:
89
+ await session.send({"type": "prompt", "prompt": "Hello world!"})
90
+ async for msg in session:
91
+ print(f"RECEIVED: {msg}")
92
+ ```
93
+
94
+
95
+ Alternatively to join a group channel:
96
+
97
+ ```python
98
+ from pattern_agentic_messaging import PASlimApp, PASlimConfig
99
+
100
+ config = PASlimConfig(
101
+ local_name="org/ns/participant/p1",
102
+ endpoint="https://slim.example.com",
103
+ auth_secret="shared-secret"
104
+ )
105
+
106
+ async with PASlimApp(config) as app:
107
+ async with await app.join_channel() as session:
108
+ async for msg in session:
109
+ print(f"Channel message: {msg}")
110
+ await session.send({"type": "response", "msg": "received"})
111
+ ```
112
+
113
+
114
+ ## Low-level usage
115
+
116
+ The API behind the decorator pattern can be used directly:
117
+
118
+ ```python
119
+ from pattern_agentic_messaging import PASlimApp, PASlimConfig
120
+
121
+ config = PASlimConfig(
122
+ local_name="org/ns/server/instance1",
123
+ endpoint="https://slim.example.com",
124
+ auth_secret="shared-secret"
125
+ )
126
+
127
+ async with PASlimApp(config) as app:
128
+ async for session, msg in app:
129
+ if not isinstance(msg, dict) or "prompt" not in msg:
130
+ await session.send({"error": "Invalid format"})
131
+ continue
132
+
133
+ result = await process(msg["prompt"])
134
+ await session.send({"result": result})
135
+ ```
136
+
@@ -0,0 +1,123 @@
1
+ # Pattern Agentic Messaging
2
+
3
+ An async SLIM wrapper with a FastAPI-like interface
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pattern_agentic_messaging
9
+ ```
10
+
11
+ ## Usage
12
+
13
+
14
+ ### Server
15
+
16
+ Route messages to decorated methods based on a _discriminator_ field
17
+ like `type`:
18
+
19
+ ```python
20
+ from pattern_agentic_messaging import PASlimApp, PASlimConfig
21
+
22
+ config = PASlimConfig(
23
+ local_name="org/ns/server/instance1",
24
+ endpoint="https://slim.example.com",
25
+ auth_secret="shared-secret",
26
+ message_discriminator="type"
27
+ )
28
+
29
+ app = PASlimApp(config)
30
+ agent = None
31
+
32
+ @app.on_session_connect
33
+ async def on_connect(session):
34
+ agent = await create_agent(...)
35
+ session.context = {
36
+ "agent": agent
37
+ }
38
+
39
+ # expects {"type": "prompt", "question": "...."}
40
+ @app.on_message('prompt')
41
+ async def handle_prompt(session, msg):
42
+ agent = session.context.get("agent")
43
+ response = await agent.ask(msg["question"])
44
+ await session.send({"type": "response", "answer": response})
45
+
46
+ # expects {"type": "status"}
47
+ @app.on_message('status')
48
+ async def handle_status(session, msg):
49
+ await session.send({"type": "status", "value": "ready"})
50
+
51
+ @app.on_message
52
+ async def handle_other(session, msg):
53
+ await session.send({"error": f"Unknown message type: {msg.get('type')}"})
54
+
55
+ app.run()
56
+ ```
57
+
58
+ Use `PASlimConfigGroup` to create a group channel.
59
+
60
+
61
+ ### Client
62
+
63
+ Connect to a specific peer:
64
+
65
+ ```python
66
+ from pattern_agentic_messaging import PASlimApp, PASlimConfig
67
+
68
+ config = PASlimConfig(
69
+ local_name="org/ns/client/instance1",
70
+ endpoint="https://slim.example.com",
71
+ auth_secret="shared-secret"
72
+ )
73
+
74
+ async with PASlimApp(config) as app:
75
+ async with await app.connect("org/ns/server/instance1") as session:
76
+ await session.send({"type": "prompt", "prompt": "Hello world!"})
77
+ async for msg in session:
78
+ print(f"RECEIVED: {msg}")
79
+ ```
80
+
81
+
82
+ Alternatively to join a group channel:
83
+
84
+ ```python
85
+ from pattern_agentic_messaging import PASlimApp, PASlimConfig
86
+
87
+ config = PASlimConfig(
88
+ local_name="org/ns/participant/p1",
89
+ endpoint="https://slim.example.com",
90
+ auth_secret="shared-secret"
91
+ )
92
+
93
+ async with PASlimApp(config) as app:
94
+ async with await app.join_channel() as session:
95
+ async for msg in session:
96
+ print(f"Channel message: {msg}")
97
+ await session.send({"type": "response", "msg": "received"})
98
+ ```
99
+
100
+
101
+ ## Low-level usage
102
+
103
+ The API behind the decorator pattern can be used directly:
104
+
105
+ ```python
106
+ from pattern_agentic_messaging import PASlimApp, PASlimConfig
107
+
108
+ config = PASlimConfig(
109
+ local_name="org/ns/server/instance1",
110
+ endpoint="https://slim.example.com",
111
+ auth_secret="shared-secret"
112
+ )
113
+
114
+ async with PASlimApp(config) as app:
115
+ async for session, msg in app:
116
+ if not isinstance(msg, dict) or "prompt" not in msg:
117
+ await session.send({"error": "Invalid format"})
118
+ continue
119
+
120
+ result = await process(msg["prompt"])
121
+ await session.send({"result": result})
122
+ ```
123
+
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pattern_agentic_messaging"
7
+ version = "0.8.1"
8
+ description = "SLIM-powered messaging"
9
+ authors = [
10
+ { name="Amos Joshua", email="amos@patternagentic.ai" }
11
+ ]
12
+ readme = "README.md"
13
+ requires-python = ">=3.11"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "slim-bindings>=0.6.3,<0.7.0"
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/pattern_agentic_messaging"]
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=9.0.1",
32
+ "pytest-asyncio>=0.21.1",
33
+ ]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
37
+ asyncio_default_fixture_loop_scope = "function"
38
+
@@ -0,0 +1,29 @@
1
+ from .config import PASlimConfig, PASlimP2PConfig, PASlimConfigGroup
2
+ from .session import PASlimSession, PASlimP2PSession, PASlimGroupSession
3
+ from .app import PASlimApp
4
+ from .types import MessagePayload
5
+ from .exceptions import (
6
+ PAMessagingError,
7
+ ConnectionError,
8
+ TimeoutError,
9
+ AuthenticationError,
10
+ SerializationError,
11
+ SessionClosedError
12
+ )
13
+
14
+ __all__ = [
15
+ "PASlimConfigBase",
16
+ "PASlimP2PConfig",
17
+ "PASlimGroupConfig",
18
+ "PASlimSession",
19
+ "PASlimP2PSession",
20
+ "PASlimGroupSession",
21
+ "PASlimApp",
22
+ "MessagePayload",
23
+ "PAMessagingError",
24
+ "ConnectionError",
25
+ "TimeoutError",
26
+ "AuthenticationError",
27
+ "SerializationError",
28
+ "SessionClosedError",
29
+ ]
@@ -0,0 +1,488 @@
1
+ import asyncio
2
+ import inspect
3
+ import slim_bindings
4
+ from typing import AsyncIterator, Optional, Literal, get_type_hints, get_origin, get_args
5
+ from .config import PASlimConfig
6
+ from .session import PASlimSession, PASlimP2PSession, PASlimGroupSession
7
+ from .auth import create_shared_secret_auth
8
+ from .types import MessagePayload
9
+ from .exceptions import AuthenticationError
10
+
11
+ try:
12
+ from pydantic import BaseModel, ValidationError
13
+ PYDANTIC_AVAILABLE = True
14
+ except ImportError:
15
+ PYDANTIC_AVAILABLE = False
16
+ BaseModel = None
17
+ ValidationError = None
18
+
19
+
20
+ def _extract_literal_value(model: type, field_name: str) -> Optional[str]:
21
+ """Extract Literal value from a Pydantic model field."""
22
+ try:
23
+ hints = get_type_hints(model)
24
+ field_type = hints.get(field_name)
25
+ if get_origin(field_type) is Literal:
26
+ args = get_args(field_type)
27
+ if args:
28
+ return args[0]
29
+ except Exception:
30
+ pass
31
+ return None
32
+
33
+
34
+ def _get_pydantic_model_from_handler(func) -> Optional[type]:
35
+ """Extract Pydantic model type from handler's msg parameter, if present."""
36
+ if not PYDANTIC_AVAILABLE:
37
+ return None
38
+ try:
39
+ hints = get_type_hints(func)
40
+ msg_type = hints.get('msg')
41
+ if msg_type and isinstance(msg_type, type) and issubclass(msg_type, BaseModel):
42
+ return msg_type
43
+ except Exception:
44
+ pass
45
+ return None
46
+
47
+ class PASlimApp:
48
+ def __init__(self, config: PASlimConfig):
49
+ self.config = config
50
+ self._app: Optional[slim_bindings.PyApp] = None
51
+ self._message_handlers = []
52
+ self._session_connect_handler = None
53
+ self._session_disconnect_handler = None
54
+ self._running = True
55
+
56
+ async def __aenter__(self):
57
+ if not self.config.auth_secret:
58
+ raise AuthenticationError("auth_secret is required")
59
+
60
+ auth_provider, auth_verifier = create_shared_secret_auth(
61
+ self.config.local_name,
62
+ self.config.auth_secret
63
+ )
64
+
65
+ parts = self.config.local_name.split('/')
66
+ if len(parts) == 3:
67
+ local_name = slim_bindings.PyName(*parts)
68
+ elif len(parts) == 4:
69
+ local_name = slim_bindings.PyName(parts[0], parts[1], parts[2])
70
+ else:
71
+ raise ValueError(f"local_name must be org/namespace/app or org/namespace/app/instance")
72
+
73
+ self._app = await slim_bindings.Slim.new(local_name, auth_provider, auth_verifier)
74
+
75
+ slim_config = {"endpoint": self.config.endpoint}
76
+ await self._app.connect(slim_config)
77
+
78
+ return self
79
+
80
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
81
+ pass
82
+
83
+ def __aiter__(self):
84
+ return self.messages()
85
+
86
+ def on_message(self, discriminator=None, value=None):
87
+ """
88
+ Decorator to register a message handler with optional filtering.
89
+
90
+ Can be used as a direct decorator or with discriminator arguments.
91
+ Supports Pydantic model type hints for automatic parsing.
92
+
93
+ Examples:
94
+ # Catch-all handler (no filter)
95
+ @app.on_message
96
+ async def handler(session, msg):
97
+ await session.send(response)
98
+
99
+ # Filtered by value (requires message_discriminator in config)
100
+ @app.on_message('prompt')
101
+ async def handler(session, msg):
102
+ # Called when msg[config.message_discriminator] == 'prompt'
103
+ await session.send(response)
104
+
105
+ # Filtered by explicit field and value (legacy)
106
+ @app.on_message('type', 'prompt')
107
+ async def handler(session, msg):
108
+ # Only called when msg['type'] == 'prompt'
109
+ await session.send(response)
110
+
111
+ # Pydantic model handler (requires message_discriminator in config)
112
+ @app.on_message
113
+ async def handler(session, msg: PromptMessage):
114
+ # msg is automatically parsed as PromptMessage
115
+ await session.send(response)
116
+ """
117
+ def _register_handler(func, disc_field, disc_value):
118
+ model = _get_pydantic_model_from_handler(func)
119
+ model_disc_value = None
120
+
121
+ if model:
122
+ if not self.config.message_discriminator:
123
+ raise ValueError(
124
+ f"Handler '{func.__name__}' uses Pydantic type hint, "
125
+ f"but config.message_discriminator is not set"
126
+ )
127
+ model_disc_value = _extract_literal_value(
128
+ model, self.config.message_discriminator
129
+ )
130
+
131
+ self._message_handlers.append({
132
+ 'discriminator': disc_field,
133
+ 'value': disc_value,
134
+ 'handler': func,
135
+ 'model': model,
136
+ 'discriminator_value': model_disc_value,
137
+ })
138
+ return func
139
+
140
+ # Direct decoration: @app.on_message
141
+ if callable(discriminator):
142
+ func = discriminator
143
+ return _register_handler(func, None, None)
144
+
145
+ # Single argument: @app.on_message('prompt') - uses config.message_discriminator
146
+ if discriminator is not None and value is None:
147
+ if not self.config.message_discriminator:
148
+ raise ValueError(
149
+ f"Single-argument @on_message('{discriminator}') requires "
150
+ f"config.message_discriminator to be set"
151
+ )
152
+ return lambda func: _register_handler(func, self.config.message_discriminator, discriminator)
153
+
154
+ # Two arguments: @app.on_message('type', 'prompt')
155
+ return lambda func: _register_handler(func, discriminator, value)
156
+
157
+ def on_session_connect(self, func):
158
+ """
159
+ Decorator to register a session connect handler.
160
+
161
+ The handler will be called when a new session is established.
162
+
163
+ Example:
164
+ @app.on_session_connect
165
+ async def handler(session):
166
+ logger.info(f"Session {session.session_id} connected")
167
+ """
168
+ self._session_connect_handler = func
169
+ return func
170
+
171
+ def on_session_disconnect(self, func):
172
+ """
173
+ Decorator to register a session disconnect handler.
174
+
175
+ The handler will be called when a session ends.
176
+
177
+ Example:
178
+ @app.on_session_disconnect
179
+ async def handler(session):
180
+ logger.info(f"Session {session.session_id} disconnected")
181
+ """
182
+ self._session_disconnect_handler = func
183
+ return func
184
+
185
+ def stop(self):
186
+ """Stop the application gracefully."""
187
+ self._running = False
188
+
189
+ def run(self):
190
+ """
191
+ Run the application with automatic event loop and signal handling.
192
+
193
+ This is a synchronous method that sets up signal handlers,
194
+ creates an event loop, and runs the async message handling loop.
195
+
196
+ Example:
197
+ app = PASlimApp(config)
198
+
199
+ @app.on_message
200
+ async def handler(session, msg):
201
+ await session.send(response)
202
+
203
+ app.run() # Blocks until stopped
204
+ """
205
+ import signal as sig
206
+
207
+ loop = asyncio.new_event_loop()
208
+ asyncio.set_event_loop(loop)
209
+
210
+ def signal_handler(signum, frame):
211
+ self.stop()
212
+
213
+ sig.signal(sig.SIGTERM, signal_handler)
214
+ sig.signal(sig.SIGINT, signal_handler)
215
+
216
+ try:
217
+ loop.run_until_complete(self._run_async())
218
+ except KeyboardInterrupt:
219
+ pass
220
+ finally:
221
+ loop.close()
222
+
223
+ async def _run_async(self):
224
+ """Internal async runner for the decorator pattern."""
225
+ import logging
226
+
227
+ if not self._message_handlers:
228
+ raise ValueError("No message handlers registered. Use @app.on_message decorator.")
229
+
230
+ # Find catch-all handler (no discriminator and no model discriminator_value)
231
+ catch_all_info = None
232
+ for handler_info in self._message_handlers:
233
+ if handler_info['discriminator'] is None and handler_info.get('discriminator_value') is None:
234
+ catch_all_info = handler_info
235
+ break
236
+
237
+ logger = logging.getLogger(__name__)
238
+ disc_field = self.config.message_discriminator
239
+
240
+ async with self:
241
+ async for session, msg in self:
242
+ if not self._running:
243
+ break
244
+
245
+ matched = False
246
+ for handler_info in self._message_handlers:
247
+ disc = handler_info['discriminator']
248
+ val = handler_info['value']
249
+ handler = handler_info['handler']
250
+ model = handler_info.get('model')
251
+ model_disc_val = handler_info.get('discriminator_value')
252
+
253
+ # Pydantic model handler
254
+ if model and isinstance(msg, dict):
255
+ # Check discriminator match (fast path)
256
+ if model_disc_val is not None:
257
+ if msg.get(disc_field) != model_disc_val:
258
+ continue # Fall through to next handler
259
+
260
+ # Try to parse
261
+ try:
262
+ parsed = model.model_validate(msg)
263
+ matched = True
264
+ try:
265
+ await handler(session, parsed)
266
+ except Exception as exc:
267
+ logger.error(f"Error in message handler: {exc}", exc_info=True)
268
+ break
269
+ except ValidationError as e:
270
+ matched = True
271
+ await session.send({
272
+ "error": "validation_error",
273
+ "details": e.errors()
274
+ })
275
+ break
276
+
277
+ # Legacy dict-based handler (skip catch-all for now)
278
+ elif disc is not None:
279
+ if isinstance(msg, dict) and msg.get(disc) == val:
280
+ matched = True
281
+ try:
282
+ await handler(session, msg)
283
+ except Exception as exc:
284
+ logger.error(f"Error in message handler: {exc}", exc_info=True)
285
+ break
286
+
287
+ # Fall back to catch-all if no specific handler matched
288
+ if not matched and catch_all_info:
289
+ handler = catch_all_info['handler']
290
+ model = catch_all_info.get('model')
291
+ try:
292
+ if model and isinstance(msg, dict):
293
+ parsed = model.model_validate(msg)
294
+ await handler(session, parsed)
295
+ else:
296
+ await handler(session, msg)
297
+ except ValidationError as e:
298
+ await session.send({
299
+ "error": "validation_error",
300
+ "details": e.errors()
301
+ })
302
+ except Exception as exc:
303
+ logger.error(f"Error in catch-all handler: {exc}", exc_info=True)
304
+ elif not matched:
305
+ logger.warning(f"No handler for message: {msg}")
306
+
307
+ async def connect(self, peer_name: str) -> PASlimP2PSession:
308
+ """
309
+ Connect to a peer (P2P Active mode).
310
+
311
+ Args:
312
+ peer_name: Peer identifier (e.g., "org/namespace/app")
313
+
314
+ Returns:
315
+ PASlimP2PSession for communicating with the peer
316
+ """
317
+ parts = peer_name.split('/')
318
+ if len(parts) >= 3:
319
+ peer = slim_bindings.PyName(parts[0], parts[1], parts[2])
320
+ else:
321
+ raise ValueError(f"peer_name must be org/namespace/app or org/namespace/app/instance")
322
+
323
+ await self._app.set_route(peer)
324
+
325
+ session_config = slim_bindings.PySessionConfiguration.PointToPoint(
326
+ peer_name=peer,
327
+ max_retries=self.config.max_retries,
328
+ timeout=self.config.timeout,
329
+ mls_enabled=self.config.mls_enabled
330
+ )
331
+ slim_session = await self._app.create_session(session_config)
332
+ return PASlimP2PSession(slim_session)
333
+
334
+ async def accept(self) -> PASlimP2PSession:
335
+ """
336
+ Accept a single incoming P2P session (P2P Passive mode).
337
+
338
+ Returns:
339
+ PASlimP2PSession for the incoming connection
340
+ """
341
+ slim_session = await self._app.listen_for_session()
342
+ return PASlimP2PSession(slim_session)
343
+
344
+ async def create_channel(self, channel_name: str, invites: list[str] = None) -> PASlimGroupSession:
345
+ """
346
+ Create a group channel and invite participants (Group Moderator mode).
347
+
348
+ Args:
349
+ channel_name: Channel identifier (e.g., "org/namespace/channel")
350
+ invites: List of participant names to invite
351
+
352
+ Returns:
353
+ PASlimGroupSession for the channel
354
+ """
355
+ if invites is None:
356
+ invites = []
357
+
358
+ parts = channel_name.split('/')
359
+ if len(parts) >= 3:
360
+ channel = slim_bindings.PyName(parts[0], parts[1], parts[2])
361
+ else:
362
+ raise ValueError(f"channel_name must be org/namespace/channel")
363
+
364
+ session_config = slim_bindings.PySessionConfiguration.Group(
365
+ channel_name=channel,
366
+ max_retries=self.config.max_retries,
367
+ timeout=self.config.timeout,
368
+ mls_enabled=self.config.mls_enabled
369
+ )
370
+ slim_session = await self._app.create_session(session_config)
371
+ session = PASlimGroupSession(slim_session)
372
+
373
+ for invite in invites:
374
+ parts = invite.split('/')
375
+ if len(parts) >= 3:
376
+ participant = slim_bindings.PyName(parts[0], parts[1], parts[2])
377
+ else:
378
+ raise ValueError(f"invite name must be org/namespace/app")
379
+ await self._app.set_route(participant)
380
+ await session.invite(invite)
381
+
382
+ return session
383
+
384
+ async def join_channel(self) -> PASlimGroupSession:
385
+ """
386
+ Join a group channel by accepting an invite (Group Participant mode).
387
+
388
+ Returns:
389
+ PASlimGroupSession for the channel
390
+ """
391
+ slim_session = await self._app.listen_for_session()
392
+ return PASlimGroupSession(slim_session)
393
+
394
+ async def listen(self) -> AsyncIterator[PASlimP2PSession]:
395
+ """
396
+ Listen for incoming P2P sessions (P2P Passive mode).
397
+
398
+ Yields:
399
+ PASlimP2PSession for each incoming connection
400
+ """
401
+ while True:
402
+ slim_session = await self._app.listen_for_session()
403
+ yield PASlimP2PSession(slim_session)
404
+
405
+ async def messages(self) -> AsyncIterator[tuple[PASlimSession, MessagePayload]]:
406
+ """
407
+ Iterate over messages from all incoming sessions.
408
+
409
+ Yields (session, message) tuples from all active sessions.
410
+ Automatically manages session lifecycle - listens for new sessions,
411
+ starts their message loops, and multiplexes messages into a single stream.
412
+
413
+ Designed for servers handling multiple concurrent clients.
414
+
415
+ Example:
416
+ async with PASlimApp(config) as app:
417
+ async for session, msg in app:
418
+ await session.send(response)
419
+ """
420
+ message_queue: asyncio.Queue = asyncio.Queue()
421
+ session_tasks: set[asyncio.Task] = set()
422
+ listener_task: Optional[asyncio.Task] = None
423
+
424
+ async def session_reader(session: PASlimSession):
425
+ """Read messages from a session and forward to queue."""
426
+ try:
427
+ # Call session connect handler if registered
428
+ if self._session_connect_handler:
429
+ try:
430
+ await self._session_connect_handler(session)
431
+ except Exception:
432
+ pass # Don't let handler errors prevent session
433
+
434
+ async with session:
435
+ async for msg in session:
436
+ await message_queue.put((session, msg))
437
+ except Exception:
438
+ pass # Session ended or errored - normal behavior
439
+ finally:
440
+ # Call session disconnect handler if registered
441
+ if self._session_disconnect_handler:
442
+ try:
443
+ await self._session_disconnect_handler(session)
444
+ except Exception:
445
+ pass # Don't let handler errors prevent cleanup
446
+
447
+ async def session_listener():
448
+ """Listen for new sessions and spawn reader tasks."""
449
+ async for session in self.listen():
450
+ task = asyncio.create_task(session_reader(session))
451
+ session_tasks.add(task)
452
+ task.add_done_callback(session_tasks.discard)
453
+
454
+ try:
455
+ listener_task = asyncio.create_task(session_listener())
456
+
457
+ while True:
458
+ # Check if listener crashed
459
+ if listener_task.done():
460
+ exc = listener_task.exception()
461
+ if exc:
462
+ raise exc
463
+ break # Listener ended (shouldn't happen)
464
+
465
+ # Get next message with timeout to periodically check listener health
466
+ try:
467
+ session, msg = await asyncio.wait_for(
468
+ message_queue.get(),
469
+ timeout=0.1
470
+ )
471
+ yield (session, msg)
472
+ except asyncio.TimeoutError:
473
+ continue # No message yet, loop back
474
+
475
+ finally:
476
+ # Cleanup: cancel all tasks
477
+ if listener_task and not listener_task.done():
478
+ listener_task.cancel()
479
+ try:
480
+ await listener_task
481
+ except asyncio.CancelledError:
482
+ pass
483
+
484
+ for task in list(session_tasks):
485
+ task.cancel()
486
+
487
+ if session_tasks:
488
+ await asyncio.gather(*session_tasks, return_exceptions=True)
@@ -0,0 +1,23 @@
1
+ import slim_bindings
2
+ from typing import Tuple
3
+
4
+ def create_shared_secret_auth(identity: str, secret: str) -> Tuple[slim_bindings.PyIdentityProvider, slim_bindings.PyIdentityVerifier]:
5
+ provider = slim_bindings.PyIdentityProvider.SharedSecret(
6
+ identity=identity,
7
+ shared_secret=secret
8
+ )
9
+ verifier = slim_bindings.PyIdentityVerifier.SharedSecret(
10
+ identity=identity,
11
+ shared_secret=secret
12
+ )
13
+ return provider, verifier
14
+
15
+ def create_jwt_auth(jwt_path: str, iss: str, sub: str, aud: str, public_key: slim_bindings.PyKey) -> Tuple[slim_bindings.PyIdentityProvider, slim_bindings.PyIdentityVerifier]:
16
+ provider = slim_bindings.PyIdentityProvider.StaticJwt(path=jwt_path)
17
+ verifier = slim_bindings.PyIdentityVerifier.Jwt(
18
+ public_key=public_key,
19
+ issuer=iss,
20
+ audience=[aud],
21
+ subject=sub
22
+ )
23
+ return provider, verifier
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import timedelta
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass
7
+ class PASlimConfig:
8
+ local_name: str
9
+ endpoint: str
10
+ auth_secret: Optional[str] = None
11
+ max_retries: int = 5
12
+ timeout: timedelta = field(default_factory=lambda: timedelta(seconds=5))
13
+ mls_enabled: bool = True
14
+ message_discriminator: Optional[str] = None
15
+
16
+
17
+ @dataclass
18
+ class PASlimConfigP2P(PASlimConfig):
19
+ peer_name: Optional[str] = None
20
+
21
+
22
+ @dataclass
23
+ class PASlimConfigGroup(PASlimConfig):
24
+ channel_name: Optional[str] = None
25
+ invites: list[str] = field(default_factory=list)
@@ -0,0 +1,17 @@
1
+ class PAMessagingError(Exception):
2
+ pass
3
+
4
+ class ConnectionError(PAMessagingError):
5
+ pass
6
+
7
+ class TimeoutError(PAMessagingError):
8
+ pass
9
+
10
+ class AuthenticationError(PAMessagingError):
11
+ pass
12
+
13
+ class SerializationError(PAMessagingError):
14
+ pass
15
+
16
+ class SessionClosedError(PAMessagingError):
17
+ pass
@@ -0,0 +1,28 @@
1
+ import json
2
+ from typing import Union
3
+ from .types import MessagePayload
4
+ from .exceptions import SerializationError
5
+
6
+ def encode_message(payload: MessagePayload) -> bytes:
7
+ if isinstance(payload, bytes):
8
+ return payload
9
+ if isinstance(payload, str):
10
+ return payload.encode('utf-8')
11
+ if isinstance(payload, dict):
12
+ try:
13
+ return json.dumps(payload).encode('utf-8')
14
+ except (TypeError, ValueError) as e:
15
+ raise SerializationError(f"Failed to encode dict: {e}")
16
+ raise SerializationError(f"Unsupported payload type: {type(payload)}")
17
+
18
+ def decode_message(data: bytes) -> Union[dict, str, bytes]:
19
+ try:
20
+ text = data.decode('utf-8', errors='strict')
21
+ if '\x00' in text:
22
+ return data
23
+ try:
24
+ return json.loads(text)
25
+ except json.JSONDecodeError:
26
+ return text
27
+ except UnicodeDecodeError:
28
+ return data
@@ -0,0 +1,130 @@
1
+ import asyncio
2
+ import uuid
3
+ from typing import Optional, Callable, Any, Dict
4
+ from datetime import timedelta
5
+ from .types import MessagePayload
6
+ from .messages import encode_message, decode_message
7
+ from .exceptions import SessionClosedError, TimeoutError as PATimeoutError
8
+
9
+ class PASlimSession:
10
+ def __init__(self, slim_session):
11
+ self._session = slim_session
12
+ self._session_id = str(uuid.uuid4())
13
+ self.context: Dict[str, Any] = {}
14
+ self._queue: asyncio.Queue = asyncio.Queue()
15
+ self._read_task: Optional[asyncio.Task] = None
16
+ self._callbacks: list[Callable] = []
17
+ self._pending_requests: dict[str, asyncio.Future] = {}
18
+ self._closed = False
19
+
20
+ @property
21
+ def session_id(self) -> str:
22
+ """Unique identifier for this session instance."""
23
+ return self._session_id
24
+
25
+ async def _read_loop(self):
26
+ while not self._closed:
27
+ try:
28
+ msg_ctx, payload = await self._session.get_message()
29
+ decoded = decode_message(payload)
30
+
31
+ if isinstance(decoded, dict) and "_request_id" in decoded:
32
+ request_id = decoded["_request_id"]
33
+ if request_id in self._pending_requests:
34
+ self._pending_requests[request_id].set_result(decoded)
35
+ continue
36
+
37
+ for callback in self._callbacks:
38
+ try:
39
+ if asyncio.iscoroutinefunction(callback):
40
+ await callback(decoded)
41
+ else:
42
+ callback(decoded)
43
+ except Exception:
44
+ pass
45
+
46
+ await self._queue.put((msg_ctx, decoded))
47
+ except Exception:
48
+ if not self._closed:
49
+ break
50
+
51
+ async def __aenter__(self):
52
+ self._read_task = asyncio.create_task(self._read_loop())
53
+ return self
54
+
55
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
56
+ self._closed = True
57
+ if self._read_task:
58
+ self._read_task.cancel()
59
+ try:
60
+ await self._read_task
61
+ except asyncio.CancelledError:
62
+ pass
63
+
64
+ def __aiter__(self):
65
+ return self
66
+
67
+ async def __anext__(self):
68
+ if self._closed:
69
+ raise StopAsyncIteration
70
+ try:
71
+ _, msg = await self._queue.get()
72
+ return msg
73
+ except asyncio.CancelledError:
74
+ raise StopAsyncIteration
75
+
76
+ async def send(self, payload: MessagePayload):
77
+ if self._closed:
78
+ raise SessionClosedError("Session is closed")
79
+ data = encode_message(payload)
80
+ await self._session.publish(data)
81
+
82
+ def on_message(self, callback: Callable[[Any], None]):
83
+ self._callbacks.append(callback)
84
+
85
+ async def request(self, payload: MessagePayload, timeout: Optional[float] = None) -> Any:
86
+ if self._closed:
87
+ raise SessionClosedError("Session is closed")
88
+
89
+ request_id = str(uuid.uuid4())
90
+ future = asyncio.Future()
91
+ self._pending_requests[request_id] = future
92
+
93
+ if isinstance(payload, dict):
94
+ payload["_request_id"] = request_id
95
+ else:
96
+ payload = {"_request_id": request_id, "data": payload}
97
+
98
+ await self.send(payload)
99
+
100
+ try:
101
+ if timeout:
102
+ return await asyncio.wait_for(future, timeout=timeout)
103
+ else:
104
+ return await future
105
+ except asyncio.TimeoutError:
106
+ raise PATimeoutError(f"Request timed out after {timeout}s")
107
+ finally:
108
+ self._pending_requests.pop(request_id, None)
109
+
110
+ class PASlimP2PSession(PASlimSession):
111
+ pass
112
+
113
+ class PASlimGroupSession(PASlimSession):
114
+ async def invite(self, participant_name: str):
115
+ import slim_bindings
116
+ parts = participant_name.split('/')
117
+ if len(parts) >= 3:
118
+ name = slim_bindings.PyName(parts[0], parts[1], parts[2])
119
+ else:
120
+ raise ValueError(f"participant_name must be org/namespace/app")
121
+ await self._session.invite(name)
122
+
123
+ async def remove(self, participant_name: str):
124
+ import slim_bindings
125
+ parts = participant_name.split('/')
126
+ if len(parts) >= 3:
127
+ name = slim_bindings.PyName(parts[0], parts[1], parts[2])
128
+ else:
129
+ raise ValueError(f"participant_name must be org/namespace/app")
130
+ await self._session.remove(name)
@@ -0,0 +1,3 @@
1
+ from typing import Union
2
+
3
+ MessagePayload = Union[bytes, str, dict]
@@ -0,0 +1,23 @@
1
+ from datetime import timedelta
2
+ from pattern_agentic_messaging import PASlimConfigP2P, PASlimConfigGroup, SessionMode, GroupMode
3
+
4
+ def test_p2p_config_defaults():
5
+ config = PASlimConfigP2P(
6
+ local_name="org/ns/app/inst",
7
+ endpoint="https://example.com",
8
+ auth_secret="secret123"
9
+ )
10
+ assert config.max_retries == 5
11
+ assert config.timeout == timedelta(seconds=5)
12
+ assert config.mls_enabled is True
13
+ assert config.mode == SessionMode.ACTIVE
14
+
15
+ def test_group_config_defaults():
16
+ config = PASlimConfigGroup(
17
+ local_name="org/ns/app/inst",
18
+ endpoint="https://example.com",
19
+ auth_secret="secret123",
20
+ channel_name="org/ns/channel"
21
+ )
22
+ assert config.mode == GroupMode.MODERATOR
23
+ assert config.invites == []
@@ -0,0 +1,33 @@
1
+ import pytest
2
+ from pattern_agentic_messaging.messages import encode_message, decode_message
3
+ from pattern_agentic_messaging.exceptions import SerializationError
4
+
5
+ def test_encode_bytes():
6
+ data = b"raw bytes"
7
+ assert encode_message(data) == data
8
+
9
+ def test_encode_str():
10
+ assert encode_message("hello") == b"hello"
11
+
12
+ def test_encode_dict():
13
+ result = encode_message({"type": "ping"})
14
+ assert result == b'{"type": "ping"}'
15
+
16
+ def test_encode_invalid():
17
+ with pytest.raises(SerializationError):
18
+ encode_message(123)
19
+
20
+ def test_decode_json():
21
+ data = b'{"type": "pong"}'
22
+ result = decode_message(data)
23
+ assert result == {"type": "pong"}
24
+
25
+ def test_decode_string():
26
+ data = b"plain text"
27
+ result = decode_message(data)
28
+ assert result == "plain text"
29
+
30
+ def test_decode_binary():
31
+ data = b"\x00\x01\x02"
32
+ result = decode_message(data)
33
+ assert result == data
@@ -0,0 +1,132 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "iniconfig"
16
+ version = "2.3.0"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
21
+ ]
22
+
23
+ [[package]]
24
+ name = "packaging"
25
+ version = "25.0"
26
+ source = { registry = "https://pypi.org/simple" }
27
+ sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
28
+ wheels = [
29
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
30
+ ]
31
+
32
+ [[package]]
33
+ name = "pattern-agentic-messaging"
34
+ version = "0.8.0"
35
+ source = { editable = "." }
36
+ dependencies = [
37
+ { name = "slim-bindings" },
38
+ ]
39
+
40
+ [package.dev-dependencies]
41
+ dev = [
42
+ { name = "pytest" },
43
+ { name = "pytest-asyncio" },
44
+ ]
45
+
46
+ [package.metadata]
47
+ requires-dist = [{ name = "slim-bindings", specifier = ">=0.6.3" }]
48
+
49
+ [package.metadata.requires-dev]
50
+ dev = [
51
+ { name = "pytest", specifier = ">=9.0.1" },
52
+ { name = "pytest-asyncio", specifier = ">=0.21.1" },
53
+ ]
54
+
55
+ [[package]]
56
+ name = "pluggy"
57
+ version = "1.6.0"
58
+ source = { registry = "https://pypi.org/simple" }
59
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
60
+ wheels = [
61
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
62
+ ]
63
+
64
+ [[package]]
65
+ name = "pygments"
66
+ version = "2.19.2"
67
+ source = { registry = "https://pypi.org/simple" }
68
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
69
+ wheels = [
70
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
71
+ ]
72
+
73
+ [[package]]
74
+ name = "pytest"
75
+ version = "9.0.1"
76
+ source = { registry = "https://pypi.org/simple" }
77
+ dependencies = [
78
+ { name = "colorama", marker = "sys_platform == 'win32'" },
79
+ { name = "iniconfig" },
80
+ { name = "packaging" },
81
+ { name = "pluggy" },
82
+ { name = "pygments" },
83
+ ]
84
+ sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" }
85
+ wheels = [
86
+ { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" },
87
+ ]
88
+
89
+ [[package]]
90
+ name = "pytest-asyncio"
91
+ version = "1.3.0"
92
+ source = { registry = "https://pypi.org/simple" }
93
+ dependencies = [
94
+ { name = "pytest" },
95
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
96
+ ]
97
+ sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
98
+ wheels = [
99
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
100
+ ]
101
+
102
+ [[package]]
103
+ name = "slim-bindings"
104
+ version = "0.6.3"
105
+ source = { registry = "https://pypi.org/simple" }
106
+ sdist = { url = "https://files.pythonhosted.org/packages/9b/09/0b33770d5389000151c032d96b61c6957ad90331179ad02601c57e8686c8/slim_bindings-0.6.3.tar.gz", hash = "sha256:bd9e272640527c7ef51e90e69c4268cd5712fa65b2fe1d9deb4f7de29122916e", size = 394752, upload-time = "2025-10-31T16:16:32.75Z" }
107
+ wheels = [
108
+ { url = "https://files.pythonhosted.org/packages/72/ed/9ace6f64460dd8d381b4ff7ce58fc063853c6a7571d598f26db70b901585/slim_bindings-0.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:01d04a307c8befdd6667262f74c85e1f8e69c36187f5772ed6822718d3b6a647", size = 8242169, upload-time = "2025-10-31T16:15:45.396Z" },
109
+ { url = "https://files.pythonhosted.org/packages/60/cb/52bee40860d13dcc158b8e98c099768276383b94450404948459d49a36ee/slim_bindings-0.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cbd3d21bb140ce6837a99145ab4c4336efed658e624bec8710471c237ed082dd", size = 7912501, upload-time = "2025-10-31T16:15:47.598Z" },
110
+ { url = "https://files.pythonhosted.org/packages/dc/23/8dfa1489fef922ded4d9855ecb8caca7f1e17041a690055b3bab15b8cb09/slim_bindings-0.6.3-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:89e026a28a84318dc3cb1e7526d81915ad069e9d89a35e462d9e466b92170008", size = 8744989, upload-time = "2025-10-31T16:15:49.686Z" },
111
+ { url = "https://files.pythonhosted.org/packages/4a/cf/5d5a27c10b0a4c04e05fa5a6a19f4cf663a3b4e90fc506e078b1358977a1/slim_bindings-0.6.3-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:50c0d8427f99a558b0ff980f19a17390876076119f12c91d3e23cd0e4bca7c55", size = 8882953, upload-time = "2025-10-31T16:15:51.524Z" },
112
+ { url = "https://files.pythonhosted.org/packages/f5/43/517f2144a9fc5d248571d14ff81f93f3a9a15de2362058c5471c77bca41a/slim_bindings-0.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:72281ef20250d3ba85af547888f5c1f9831a422a5ede1bb7c878d3b2b537d2fd", size = 7292432, upload-time = "2025-10-31T16:15:53.787Z" },
113
+ { url = "https://files.pythonhosted.org/packages/05/62/74ca0c91c3379bc8e072ffec8ee012105f3e47bc5c84549cac346c83760b/slim_bindings-0.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a8af60585e2488434a50a8dfc46a0f7e32a858925cd6eb25329248b6d3c02fe5", size = 8237044, upload-time = "2025-10-31T16:16:01.952Z" },
114
+ { url = "https://files.pythonhosted.org/packages/20/64/b9264200cd5bfbaa5077953fc406232fd8bc9e26aa3bc8bf5dd96de6e6fa/slim_bindings-0.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7eaeb154294744ae0c087d1bda235f6794bb351108448a38bf5ffe18d5f8fd0", size = 7905819, upload-time = "2025-10-31T16:16:04.911Z" },
115
+ { url = "https://files.pythonhosted.org/packages/ce/6b/e701bc4a44e9ba5292557baee205d453890ab989225c9842b05b3440ddf8/slim_bindings-0.6.3-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:dac66eb1d6e619d8dba7d8ea75781883457a40cc85159eaac4401d044c9934d2", size = 8756849, upload-time = "2025-10-31T16:16:07.403Z" },
116
+ { url = "https://files.pythonhosted.org/packages/05/2a/6197e2cfb9c2ff55ade9ef1a545cb006171296e474ccfc52e909cf8055ca/slim_bindings-0.6.3-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:bd7ee891a08da0dd1c96c8fa825ae60eab99356c052870cbd490f52bef0147ac", size = 8891589, upload-time = "2025-10-31T16:16:09.139Z" },
117
+ { url = "https://files.pythonhosted.org/packages/0e/32/e1dbb223a7ed65c6e28fe714f888dff725a16a42eecdde993999d281665a/slim_bindings-0.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:1e3cde1d33db1e3bace6c5b80d79f3031f9a8cce0896495873a481b80372882c", size = 7302666, upload-time = "2025-10-31T16:16:10.812Z" },
118
+ { url = "https://files.pythonhosted.org/packages/f0/f5/69737a3d263396a18eb84a70483f924f3356be3f81b6660710e84e2ae04d/slim_bindings-0.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e24a2ff9ef307790ec114f4bfa158c49dfe0c988ec70c1fe315b4733c672cf69", size = 8237490, upload-time = "2025-10-31T16:16:12.433Z" },
119
+ { url = "https://files.pythonhosted.org/packages/31/ff/cab83e16d0595c6ff3a0bbc3e42eaf69a328b183dc5197a150c599c25468/slim_bindings-0.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b55894c30ea608d44eaa5f008ccd016428e2fbdf3b161b073889cd9dbc9c12d5", size = 7906161, upload-time = "2025-10-31T16:16:14.002Z" },
120
+ { url = "https://files.pythonhosted.org/packages/6e/16/2cbdedab433e9349ef58263db5843e864554de4d203b993ebe1930d7ce44/slim_bindings-0.6.3-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:d73069dfdbff0ac0e651ba9b5b76cb6932e633d652467bec66e8e22ec79a7228", size = 8756685, upload-time = "2025-10-31T16:16:15.72Z" },
121
+ { url = "https://files.pythonhosted.org/packages/aa/ef/bc746c42162f8611309dab09c11f3d5d063a2f664c27826c8775e6503d5a/slim_bindings-0.6.3-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:27c6bd7b18128eb449d01c92ac0f2c5c28dde83c8173c6cb55950fe921834790", size = 8891357, upload-time = "2025-10-31T16:16:17.517Z" },
122
+ { url = "https://files.pythonhosted.org/packages/62/4c/3d2a141c56440b5c065106cf6a57f44f22ed8543dba35d06c766c8ff01a2/slim_bindings-0.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:0e928d4cac235f942794ebace44245de4384c3fe9fc74f4889846964707ebc90", size = 7302378, upload-time = "2025-10-31T16:16:20.009Z" },
123
+ ]
124
+
125
+ [[package]]
126
+ name = "typing-extensions"
127
+ version = "4.15.0"
128
+ source = { registry = "https://pypi.org/simple" }
129
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
130
+ wheels = [
131
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
132
+ ]