nodus-adapter-base 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nodus_adapter_base-0.1.0/PKG-INFO +122 -0
- nodus_adapter_base-0.1.0/README.md +107 -0
- nodus_adapter_base-0.1.0/nodus_adapter_base/__init__.py +14 -0
- nodus_adapter_base-0.1.0/nodus_adapter_base/adapter.py +148 -0
- nodus_adapter_base-0.1.0/nodus_adapter_base/manager.py +65 -0
- nodus_adapter_base-0.1.0/nodus_adapter_base.egg-info/PKG-INFO +122 -0
- nodus_adapter_base-0.1.0/nodus_adapter_base.egg-info/SOURCES.txt +11 -0
- nodus_adapter_base-0.1.0/nodus_adapter_base.egg-info/dependency_links.txt +1 -0
- nodus_adapter_base-0.1.0/nodus_adapter_base.egg-info/requires.txt +5 -0
- nodus_adapter_base-0.1.0/nodus_adapter_base.egg-info/top_level.txt +1 -0
- nodus_adapter_base-0.1.0/pyproject.toml +28 -0
- nodus_adapter_base-0.1.0/setup.cfg +4 -0
- nodus_adapter_base-0.1.0/tests/test_adapter_base.py +148 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodus-adapter-base
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Abstract base for nodus channel adapters: reconnect loop, health recording, connection manager
|
|
5
|
+
Author: Shawn Knight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Masterplanner25/nodus-adapters
|
|
8
|
+
Project-URL: Repository, https://github.com/Masterplanner25/nodus-adapters
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: nodus-channels>=0.1.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# nodus-adapter-base
|
|
17
|
+
|
|
18
|
+
**Abstract base class for [nodus-channels](https://github.com/Masterplanner25/nodus-channels) adapters.**
|
|
19
|
+
|
|
20
|
+
Provides the reconnect loop, health recording, and connection management
|
|
21
|
+
so concrete adapters (Slack, Discord, webhook, etc.) only implement the
|
|
22
|
+
three transport-specific methods.
|
|
23
|
+
|
|
24
|
+
> **Status:** v0.1.0 — prepared, not yet published.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install nodus-adapter-base
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires `nodus-channels>=0.1.0`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## What it provides
|
|
39
|
+
|
|
40
|
+
| Class | Purpose |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `BaseChannelAdapter` | Abstract base with `connect()` retry backoff, health recording, `send()`, `subscribe()` |
|
|
43
|
+
| `ConnectionManager` | `start_all()`, `stop_all()`, `health_check_all()` across a `ChannelRegistry` |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Implementing an adapter
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from nodus_channels import ChannelInfo, Message
|
|
51
|
+
from nodus_adapter_base import BaseChannelAdapter
|
|
52
|
+
|
|
53
|
+
class SlackAdapter(BaseChannelAdapter):
|
|
54
|
+
@property
|
|
55
|
+
def channel_id(self) -> str:
|
|
56
|
+
return "slack"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def info(self) -> ChannelInfo:
|
|
60
|
+
return ChannelInfo(id="slack", display_name="Slack")
|
|
61
|
+
|
|
62
|
+
async def _do_connect(self) -> None:
|
|
63
|
+
# establish websocket / API connection
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
async def _do_send(self, content, peer_id, *, thread_id=None,
|
|
67
|
+
reply_to_id=None, attachments=None) -> str:
|
|
68
|
+
# send message; return message ID
|
|
69
|
+
return "slack-msg-id"
|
|
70
|
+
|
|
71
|
+
def _do_subscribe(self):
|
|
72
|
+
# return an async generator yielding Message objects
|
|
73
|
+
async def _gen():
|
|
74
|
+
...
|
|
75
|
+
yield message
|
|
76
|
+
return _gen()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Reconnect backoff
|
|
80
|
+
|
|
81
|
+
`connect()` retries up to `max_reconnect_attempts` times (default 5) using
|
|
82
|
+
the backoff schedule `[1, 5, 30, 60, 300]` seconds. Each failure is recorded
|
|
83
|
+
on the `HealthMonitor`. Raises the last exception if all attempts fail.
|
|
84
|
+
|
|
85
|
+
### Health recording
|
|
86
|
+
|
|
87
|
+
Every `connect()`, `send()`, and `health_check()` call records success or
|
|
88
|
+
failure on the adapter's `HealthMonitor`. Access it via `adapter.health_monitor`.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## ConnectionManager
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from nodus_channels import ChannelRegistry
|
|
96
|
+
from nodus_adapter_base import ConnectionManager
|
|
97
|
+
|
|
98
|
+
registry = ChannelRegistry()
|
|
99
|
+
registry.register(SlackAdapter())
|
|
100
|
+
registry.register(DiscordAdapter())
|
|
101
|
+
|
|
102
|
+
manager = ConnectionManager(registry)
|
|
103
|
+
results = await manager.start_all() # {"slack": True, "discord": True}
|
|
104
|
+
health = await manager.health_check_all() # {"slack": True, "discord": False}
|
|
105
|
+
await manager.stop_all()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cd base
|
|
114
|
+
pip install -e ".[dev]"
|
|
115
|
+
pytest tests/ -q
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT — see [LICENSE](../LICENSE).
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# nodus-adapter-base
|
|
2
|
+
|
|
3
|
+
**Abstract base class for [nodus-channels](https://github.com/Masterplanner25/nodus-channels) adapters.**
|
|
4
|
+
|
|
5
|
+
Provides the reconnect loop, health recording, and connection management
|
|
6
|
+
so concrete adapters (Slack, Discord, webhook, etc.) only implement the
|
|
7
|
+
three transport-specific methods.
|
|
8
|
+
|
|
9
|
+
> **Status:** v0.1.0 — prepared, not yet published.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install nodus-adapter-base
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires `nodus-channels>=0.1.0`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What it provides
|
|
24
|
+
|
|
25
|
+
| Class | Purpose |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `BaseChannelAdapter` | Abstract base with `connect()` retry backoff, health recording, `send()`, `subscribe()` |
|
|
28
|
+
| `ConnectionManager` | `start_all()`, `stop_all()`, `health_check_all()` across a `ChannelRegistry` |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Implementing an adapter
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from nodus_channels import ChannelInfo, Message
|
|
36
|
+
from nodus_adapter_base import BaseChannelAdapter
|
|
37
|
+
|
|
38
|
+
class SlackAdapter(BaseChannelAdapter):
|
|
39
|
+
@property
|
|
40
|
+
def channel_id(self) -> str:
|
|
41
|
+
return "slack"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def info(self) -> ChannelInfo:
|
|
45
|
+
return ChannelInfo(id="slack", display_name="Slack")
|
|
46
|
+
|
|
47
|
+
async def _do_connect(self) -> None:
|
|
48
|
+
# establish websocket / API connection
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
async def _do_send(self, content, peer_id, *, thread_id=None,
|
|
52
|
+
reply_to_id=None, attachments=None) -> str:
|
|
53
|
+
# send message; return message ID
|
|
54
|
+
return "slack-msg-id"
|
|
55
|
+
|
|
56
|
+
def _do_subscribe(self):
|
|
57
|
+
# return an async generator yielding Message objects
|
|
58
|
+
async def _gen():
|
|
59
|
+
...
|
|
60
|
+
yield message
|
|
61
|
+
return _gen()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Reconnect backoff
|
|
65
|
+
|
|
66
|
+
`connect()` retries up to `max_reconnect_attempts` times (default 5) using
|
|
67
|
+
the backoff schedule `[1, 5, 30, 60, 300]` seconds. Each failure is recorded
|
|
68
|
+
on the `HealthMonitor`. Raises the last exception if all attempts fail.
|
|
69
|
+
|
|
70
|
+
### Health recording
|
|
71
|
+
|
|
72
|
+
Every `connect()`, `send()`, and `health_check()` call records success or
|
|
73
|
+
failure on the adapter's `HealthMonitor`. Access it via `adapter.health_monitor`.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## ConnectionManager
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from nodus_channels import ChannelRegistry
|
|
81
|
+
from nodus_adapter_base import ConnectionManager
|
|
82
|
+
|
|
83
|
+
registry = ChannelRegistry()
|
|
84
|
+
registry.register(SlackAdapter())
|
|
85
|
+
registry.register(DiscordAdapter())
|
|
86
|
+
|
|
87
|
+
manager = ConnectionManager(registry)
|
|
88
|
+
results = await manager.start_all() # {"slack": True, "discord": True}
|
|
89
|
+
health = await manager.health_check_all() # {"slack": True, "discord": False}
|
|
90
|
+
await manager.stop_all()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cd base
|
|
99
|
+
pip install -e ".[dev]"
|
|
100
|
+
pytest tests/ -q
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT — see [LICENSE](../LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""nodus-adapter-base — abstract base for channel adapters.
|
|
2
|
+
|
|
3
|
+
BaseChannelAdapter:
|
|
4
|
+
- connect() with exponential backoff retry
|
|
5
|
+
- Health recording via HealthMonitor
|
|
6
|
+
- Abstract interface: _do_connect, _do_send, _do_subscribe
|
|
7
|
+
|
|
8
|
+
ConnectionManager:
|
|
9
|
+
- start_all() / stop_all() / health_check_all()
|
|
10
|
+
"""
|
|
11
|
+
from .adapter import BaseChannelAdapter
|
|
12
|
+
from .manager import ConnectionManager
|
|
13
|
+
|
|
14
|
+
__all__ = ["BaseChannelAdapter", "ConnectionManager"]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""BaseChannelAdapter — abstract base with reconnect loop and health recording."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import AsyncIterator, Optional
|
|
8
|
+
|
|
9
|
+
from nodus_channels import Attachment, ChannelInfo, ChannelRegistry, Message
|
|
10
|
+
from nodus_channels.health import HealthMonitor
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_RECONNECT_BACKOFF: list[float] = [1.0, 5.0, 30.0, 60.0, 300.0]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseChannelAdapter(ABC):
|
|
18
|
+
"""Abstract base class for channel adapters.
|
|
19
|
+
|
|
20
|
+
Provides:
|
|
21
|
+
- ``connect()`` with exponential backoff retry
|
|
22
|
+
- Health recording via ``HealthMonitor``
|
|
23
|
+
- Default ``health_check()`` delegating to ``_do_health_check()``
|
|
24
|
+
|
|
25
|
+
Subclass this and implement ``_do_connect``, ``_do_send``,
|
|
26
|
+
``_do_subscribe``, and optionally ``_do_health_check``.
|
|
27
|
+
|
|
28
|
+
Usage::
|
|
29
|
+
|
|
30
|
+
class MyAdapter(BaseChannelAdapter):
|
|
31
|
+
@property
|
|
32
|
+
def channel_id(self): return "my-channel"
|
|
33
|
+
@property
|
|
34
|
+
def info(self): return ChannelInfo("my-channel", "My Channel")
|
|
35
|
+
async def _do_connect(self): ...
|
|
36
|
+
async def _do_send(self, content, peer_id, **kwargs): return "msg-id"
|
|
37
|
+
def _do_subscribe(self): ...
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
health_monitor: Optional[HealthMonitor] = None,
|
|
44
|
+
max_reconnect_attempts: int = 5,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._health = health_monitor or HealthMonitor()
|
|
47
|
+
self._max_reconnect = max_reconnect_attempts
|
|
48
|
+
self._connected = False
|
|
49
|
+
|
|
50
|
+
# ── Abstract interface ────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def channel_id(self) -> str: ...
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def info(self) -> ChannelInfo: ...
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def _do_connect(self) -> None: ...
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
async def _do_send(
|
|
65
|
+
self,
|
|
66
|
+
content: str,
|
|
67
|
+
peer_id: str,
|
|
68
|
+
*,
|
|
69
|
+
thread_id: Optional[str] = None,
|
|
70
|
+
reply_to_id: Optional[str] = None,
|
|
71
|
+
attachments: Optional[list[Attachment]] = None,
|
|
72
|
+
) -> str: ...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def _do_subscribe(self) -> AsyncIterator[Message]: ...
|
|
76
|
+
|
|
77
|
+
# ── Implemented methods ───────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async def connect(self) -> None:
|
|
80
|
+
"""Connect with exponential backoff retry on failure."""
|
|
81
|
+
for attempt, delay in enumerate(_RECONNECT_BACKOFF[: self._max_reconnect]):
|
|
82
|
+
try:
|
|
83
|
+
await self._do_connect()
|
|
84
|
+
self._connected = True
|
|
85
|
+
self._health.record_success(self.channel_id)
|
|
86
|
+
logger.info("[%s] connected", self.channel_id)
|
|
87
|
+
return
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
self._health.record_failure(self.channel_id, str(exc))
|
|
90
|
+
if attempt < self._max_reconnect - 1:
|
|
91
|
+
logger.warning(
|
|
92
|
+
"[%s] connect failed (attempt %d): %s — retry in %.1fs",
|
|
93
|
+
self.channel_id, attempt + 1, exc, delay,
|
|
94
|
+
)
|
|
95
|
+
await asyncio.sleep(delay)
|
|
96
|
+
else:
|
|
97
|
+
logger.error("[%s] connect failed after %d attempts: %s",
|
|
98
|
+
self.channel_id, self._max_reconnect, exc)
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
async def disconnect(self) -> None:
|
|
102
|
+
self._connected = False
|
|
103
|
+
logger.info("[%s] disconnected", self.channel_id)
|
|
104
|
+
|
|
105
|
+
async def send(
|
|
106
|
+
self,
|
|
107
|
+
content: str,
|
|
108
|
+
peer_id: str,
|
|
109
|
+
*,
|
|
110
|
+
thread_id: Optional[str] = None,
|
|
111
|
+
reply_to_id: Optional[str] = None,
|
|
112
|
+
attachments: Optional[list[Attachment]] = None,
|
|
113
|
+
) -> str:
|
|
114
|
+
try:
|
|
115
|
+
msg_id = await self._do_send(
|
|
116
|
+
content, peer_id,
|
|
117
|
+
thread_id=thread_id,
|
|
118
|
+
reply_to_id=reply_to_id,
|
|
119
|
+
attachments=attachments,
|
|
120
|
+
)
|
|
121
|
+
self._health.record_success(self.channel_id)
|
|
122
|
+
return msg_id
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
self._health.record_failure(self.channel_id, str(exc))
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
def subscribe(self) -> AsyncIterator[Message]:
|
|
128
|
+
return self._do_subscribe()
|
|
129
|
+
|
|
130
|
+
async def health_check(self) -> bool:
|
|
131
|
+
try:
|
|
132
|
+
result = await self._do_health_check()
|
|
133
|
+
if result:
|
|
134
|
+
self._health.record_success(self.channel_id)
|
|
135
|
+
else:
|
|
136
|
+
self._health.record_failure(self.channel_id, "health check returned False")
|
|
137
|
+
return result
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
self._health.record_failure(self.channel_id, str(exc))
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
async def _do_health_check(self) -> bool:
|
|
143
|
+
"""Override to provide a real health check. Default: True if connected."""
|
|
144
|
+
return self._connected
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def health_monitor(self) -> HealthMonitor:
|
|
148
|
+
return self._health
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""ConnectionManager — start and stop all registered channel adapters."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from nodus_channels import ChannelRegistry
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConnectionManager:
|
|
14
|
+
"""Start and gracefully stop all adapters in a ``ChannelRegistry``.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
registry = ChannelRegistry()
|
|
19
|
+
registry.register(MyAdapter())
|
|
20
|
+
manager = ConnectionManager(registry)
|
|
21
|
+
await manager.start_all()
|
|
22
|
+
# ... run forever ...
|
|
23
|
+
await manager.stop_all()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, registry: ChannelRegistry) -> None:
|
|
27
|
+
self._registry = registry
|
|
28
|
+
|
|
29
|
+
async def start_all(self) -> dict[str, bool]:
|
|
30
|
+
"""Connect all registered adapters concurrently.
|
|
31
|
+
|
|
32
|
+
Returns a dict of ``{channel_id: success}`` for each adapter.
|
|
33
|
+
"""
|
|
34
|
+
adapters = self._registry.list()
|
|
35
|
+
results: dict[str, bool] = {}
|
|
36
|
+
for adapter in adapters:
|
|
37
|
+
try:
|
|
38
|
+
await adapter.connect()
|
|
39
|
+
results[adapter.channel_id] = True
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
logger.error("[ConnectionManager] failed to connect %s: %s",
|
|
42
|
+
adapter.channel_id, exc)
|
|
43
|
+
results[adapter.channel_id] = False
|
|
44
|
+
return results
|
|
45
|
+
|
|
46
|
+
async def stop_all(self) -> None:
|
|
47
|
+
"""Disconnect all registered adapters."""
|
|
48
|
+
adapters = self._registry.list()
|
|
49
|
+
for adapter in adapters:
|
|
50
|
+
try:
|
|
51
|
+
await adapter.disconnect()
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
logger.warning("[ConnectionManager] error disconnecting %s: %s",
|
|
54
|
+
adapter.channel_id, exc)
|
|
55
|
+
|
|
56
|
+
async def health_check_all(self) -> dict[str, bool]:
|
|
57
|
+
"""Run health_check() on all adapters. Returns {channel_id: healthy}."""
|
|
58
|
+
adapters = self._registry.list()
|
|
59
|
+
results: dict[str, bool] = {}
|
|
60
|
+
for adapter in adapters:
|
|
61
|
+
try:
|
|
62
|
+
results[adapter.channel_id] = await adapter.health_check()
|
|
63
|
+
except Exception:
|
|
64
|
+
results[adapter.channel_id] = False
|
|
65
|
+
return results
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodus-adapter-base
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Abstract base for nodus channel adapters: reconnect loop, health recording, connection manager
|
|
5
|
+
Author: Shawn Knight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Masterplanner25/nodus-adapters
|
|
8
|
+
Project-URL: Repository, https://github.com/Masterplanner25/nodus-adapters
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: nodus-channels>=0.1.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# nodus-adapter-base
|
|
17
|
+
|
|
18
|
+
**Abstract base class for [nodus-channels](https://github.com/Masterplanner25/nodus-channels) adapters.**
|
|
19
|
+
|
|
20
|
+
Provides the reconnect loop, health recording, and connection management
|
|
21
|
+
so concrete adapters (Slack, Discord, webhook, etc.) only implement the
|
|
22
|
+
three transport-specific methods.
|
|
23
|
+
|
|
24
|
+
> **Status:** v0.1.0 — prepared, not yet published.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install nodus-adapter-base
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires `nodus-channels>=0.1.0`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## What it provides
|
|
39
|
+
|
|
40
|
+
| Class | Purpose |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `BaseChannelAdapter` | Abstract base with `connect()` retry backoff, health recording, `send()`, `subscribe()` |
|
|
43
|
+
| `ConnectionManager` | `start_all()`, `stop_all()`, `health_check_all()` across a `ChannelRegistry` |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Implementing an adapter
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from nodus_channels import ChannelInfo, Message
|
|
51
|
+
from nodus_adapter_base import BaseChannelAdapter
|
|
52
|
+
|
|
53
|
+
class SlackAdapter(BaseChannelAdapter):
|
|
54
|
+
@property
|
|
55
|
+
def channel_id(self) -> str:
|
|
56
|
+
return "slack"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def info(self) -> ChannelInfo:
|
|
60
|
+
return ChannelInfo(id="slack", display_name="Slack")
|
|
61
|
+
|
|
62
|
+
async def _do_connect(self) -> None:
|
|
63
|
+
# establish websocket / API connection
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
async def _do_send(self, content, peer_id, *, thread_id=None,
|
|
67
|
+
reply_to_id=None, attachments=None) -> str:
|
|
68
|
+
# send message; return message ID
|
|
69
|
+
return "slack-msg-id"
|
|
70
|
+
|
|
71
|
+
def _do_subscribe(self):
|
|
72
|
+
# return an async generator yielding Message objects
|
|
73
|
+
async def _gen():
|
|
74
|
+
...
|
|
75
|
+
yield message
|
|
76
|
+
return _gen()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Reconnect backoff
|
|
80
|
+
|
|
81
|
+
`connect()` retries up to `max_reconnect_attempts` times (default 5) using
|
|
82
|
+
the backoff schedule `[1, 5, 30, 60, 300]` seconds. Each failure is recorded
|
|
83
|
+
on the `HealthMonitor`. Raises the last exception if all attempts fail.
|
|
84
|
+
|
|
85
|
+
### Health recording
|
|
86
|
+
|
|
87
|
+
Every `connect()`, `send()`, and `health_check()` call records success or
|
|
88
|
+
failure on the adapter's `HealthMonitor`. Access it via `adapter.health_monitor`.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## ConnectionManager
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from nodus_channels import ChannelRegistry
|
|
96
|
+
from nodus_adapter_base import ConnectionManager
|
|
97
|
+
|
|
98
|
+
registry = ChannelRegistry()
|
|
99
|
+
registry.register(SlackAdapter())
|
|
100
|
+
registry.register(DiscordAdapter())
|
|
101
|
+
|
|
102
|
+
manager = ConnectionManager(registry)
|
|
103
|
+
results = await manager.start_all() # {"slack": True, "discord": True}
|
|
104
|
+
health = await manager.health_check_all() # {"slack": True, "discord": False}
|
|
105
|
+
await manager.stop_all()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cd base
|
|
114
|
+
pip install -e ".[dev]"
|
|
115
|
+
pytest tests/ -q
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT — see [LICENSE](../LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
nodus_adapter_base/__init__.py
|
|
4
|
+
nodus_adapter_base/adapter.py
|
|
5
|
+
nodus_adapter_base/manager.py
|
|
6
|
+
nodus_adapter_base.egg-info/PKG-INFO
|
|
7
|
+
nodus_adapter_base.egg-info/SOURCES.txt
|
|
8
|
+
nodus_adapter_base.egg-info/dependency_links.txt
|
|
9
|
+
nodus_adapter_base.egg-info/requires.txt
|
|
10
|
+
nodus_adapter_base.egg-info/top_level.txt
|
|
11
|
+
tests/test_adapter_base.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodus_adapter_base
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nodus-adapter-base"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Abstract base for nodus channel adapters: reconnect loop, health recording, connection manager"
|
|
9
|
+
authors = [{ name = "Shawn Knight" }]
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = ["nodus-channels>=0.1.0"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.21"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/Masterplanner25/nodus-adapters"
|
|
20
|
+
Repository = "https://github.com/Masterplanner25/nodus-adapters"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["."]
|
|
24
|
+
include = ["nodus_adapter_base*"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
asyncio_mode = "auto"
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""nodus-adapter-base tests."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from nodus_channels import ChannelInfo, ChannelRegistry, Message
|
|
6
|
+
from nodus_adapter_base import BaseChannelAdapter, ConnectionManager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _StubAdapter(BaseChannelAdapter):
|
|
10
|
+
"""Minimal concrete adapter for testing."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, channel="test", fail_connect=False, **kw):
|
|
13
|
+
super().__init__(**kw)
|
|
14
|
+
self._channel = channel
|
|
15
|
+
self._fail_connect = fail_connect
|
|
16
|
+
self.connect_calls = 0
|
|
17
|
+
self.send_calls = []
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def channel_id(self): return self._channel
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def info(self): return ChannelInfo(id=self._channel, display_name=self._channel.title())
|
|
24
|
+
|
|
25
|
+
async def _do_connect(self):
|
|
26
|
+
self.connect_calls += 1
|
|
27
|
+
if self._fail_connect:
|
|
28
|
+
raise ConnectionError("deliberate failure")
|
|
29
|
+
|
|
30
|
+
async def _do_send(self, content, peer_id, **kwargs):
|
|
31
|
+
self.send_calls.append((content, peer_id))
|
|
32
|
+
return f"msg-{len(self.send_calls)}"
|
|
33
|
+
|
|
34
|
+
def _do_subscribe(self):
|
|
35
|
+
async def _gen():
|
|
36
|
+
if False: yield # empty async generator
|
|
37
|
+
return _gen()
|
|
38
|
+
|
|
39
|
+
async def _do_health_check(self): return self._connected
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── connect / disconnect ──────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_connect_sets_connected():
|
|
46
|
+
adapter = _StubAdapter()
|
|
47
|
+
await adapter.connect()
|
|
48
|
+
assert adapter._connected is True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_connect_records_health_success():
|
|
53
|
+
adapter = _StubAdapter()
|
|
54
|
+
await adapter.connect()
|
|
55
|
+
snap = adapter.health_monitor.snapshot("test")
|
|
56
|
+
assert snap.failure_count == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_connect_failure_records_health():
|
|
61
|
+
adapter = _StubAdapter(fail_connect=True, max_reconnect_attempts=1)
|
|
62
|
+
with pytest.raises(ConnectionError):
|
|
63
|
+
await adapter.connect()
|
|
64
|
+
snap = adapter.health_monitor.snapshot("test")
|
|
65
|
+
assert snap.failure_count >= 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_disconnect_clears_connected():
|
|
70
|
+
adapter = _StubAdapter()
|
|
71
|
+
await adapter.connect()
|
|
72
|
+
await adapter.disconnect()
|
|
73
|
+
assert adapter._connected is False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── send ──────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio
|
|
79
|
+
async def test_send_returns_msg_id():
|
|
80
|
+
adapter = _StubAdapter()
|
|
81
|
+
await adapter.connect()
|
|
82
|
+
msg_id = await adapter.send("hello", "peer-1")
|
|
83
|
+
assert msg_id == "msg-1"
|
|
84
|
+
assert adapter.send_calls[0] == ("hello", "peer-1")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_send_failure_records_health():
|
|
89
|
+
class _FailSend(_StubAdapter):
|
|
90
|
+
async def _do_send(self, *a, **kw):
|
|
91
|
+
raise RuntimeError("send failed")
|
|
92
|
+
|
|
93
|
+
adapter = _FailSend()
|
|
94
|
+
await adapter.connect()
|
|
95
|
+
with pytest.raises(RuntimeError):
|
|
96
|
+
await adapter.send("hello", "peer-1")
|
|
97
|
+
snap = adapter.health_monitor.snapshot("test")
|
|
98
|
+
assert snap.failure_count >= 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── health_check ──────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_health_check_true_when_connected():
|
|
105
|
+
adapter = _StubAdapter()
|
|
106
|
+
await adapter.connect()
|
|
107
|
+
assert await adapter.health_check() is True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_health_check_false_when_disconnected():
|
|
112
|
+
adapter = _StubAdapter()
|
|
113
|
+
# Not connected yet
|
|
114
|
+
assert await adapter.health_check() is False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── ConnectionManager ─────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_manager_start_all():
|
|
121
|
+
registry = ChannelRegistry()
|
|
122
|
+
registry.register(_StubAdapter("a"))
|
|
123
|
+
registry.register(_StubAdapter("b"))
|
|
124
|
+
manager = ConnectionManager(registry)
|
|
125
|
+
results = await manager.start_all()
|
|
126
|
+
assert results == {"a": True, "b": True}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.asyncio
|
|
130
|
+
async def test_manager_start_all_partial_failure():
|
|
131
|
+
registry = ChannelRegistry()
|
|
132
|
+
registry.register(_StubAdapter("ok"))
|
|
133
|
+
registry.register(_StubAdapter("fail", fail_connect=True, max_reconnect_attempts=1))
|
|
134
|
+
manager = ConnectionManager(registry)
|
|
135
|
+
results = await manager.start_all()
|
|
136
|
+
assert results["ok"] is True
|
|
137
|
+
assert results["fail"] is False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.mark.asyncio
|
|
141
|
+
async def test_manager_health_check_all():
|
|
142
|
+
registry = ChannelRegistry()
|
|
143
|
+
a = _StubAdapter("a")
|
|
144
|
+
await a.connect()
|
|
145
|
+
registry.register(a)
|
|
146
|
+
manager = ConnectionManager(registry)
|
|
147
|
+
results = await manager.health_check_all()
|
|
148
|
+
assert results["a"] is True
|