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.
- pattern_agentic_messaging-0.8.1/.claude/settings.local.json +9 -0
- pattern_agentic_messaging-0.8.1/.gitignore +3 -0
- pattern_agentic_messaging-0.8.1/LICENSE.md +20 -0
- pattern_agentic_messaging-0.8.1/PKG-INFO +136 -0
- pattern_agentic_messaging-0.8.1/README.md +123 -0
- pattern_agentic_messaging-0.8.1/pyproject.toml +38 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/__init__.py +29 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/app.py +488 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/auth.py +23 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/config.py +25 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/exceptions.py +17 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/messages.py +28 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/messaging.py +0 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/session.py +130 -0
- pattern_agentic_messaging-0.8.1/src/pattern_agentic_messaging/types.py +3 -0
- pattern_agentic_messaging-0.8.1/tests/test_config.py +23 -0
- pattern_agentic_messaging-0.8.1/tests/test_messages.py +33 -0
- pattern_agentic_messaging-0.8.1/uv.lock +132 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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,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
|
+
]
|