smart-messaging-core 0.1.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.
- smart_messaging_core-0.1.1/LICENSE +21 -0
- smart_messaging_core-0.1.1/PKG-INFO +63 -0
- smart_messaging_core-0.1.1/README.md +35 -0
- smart_messaging_core-0.1.1/pyproject.toml +46 -0
- smart_messaging_core-0.1.1/setup.cfg +4 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/__init__.py +37 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/base.py +42 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/client.py +101 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/__init__.py +19 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/http.py +135 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/kafka.py +60 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/mqtt.py +154 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/redis.py +56 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core/registry.py +17 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/PKG-INFO +63 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/SOURCES.txt +20 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/dependency_links.txt +1 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/requires.txt +7 -0
- smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/top_level.txt +1 -0
- smart_messaging_core-0.1.1/tests/test_client_routes.py +72 -0
- smart_messaging_core-0.1.1/tests/test_imports.py +20 -0
- smart_messaging_core-0.1.1/tests/test_protocol_validation.py +74 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 allenshie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smart-messaging-core
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Messaging helpers for edge/integration services.
|
|
5
|
+
Author: SmartWarehousing Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/allenshie/smart_messaging_core
|
|
8
|
+
Project-URL: Repository, https://github.com/allenshie/smart_messaging_core
|
|
9
|
+
Project-URL: Issues, https://github.com/allenshie/smart_messaging_core/issues
|
|
10
|
+
Keywords: messaging,mqtt,http,kafka,redis
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: System :: Networking
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: paho-mqtt>=1.6.1
|
|
22
|
+
Requires-Dist: kafka-python>=2.0.2
|
|
23
|
+
Requires-Dist: redis>=5.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2.2; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=5.1.1; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# smart_messaging_core
|
|
30
|
+
|
|
31
|
+
可擴充的通訊核心套件,提供統一的 `MessagingClient` facade,並可依 route 將訊息導向不同協議。
|
|
32
|
+
|
|
33
|
+
## 目前支援的協議
|
|
34
|
+
|
|
35
|
+
- `mqtt`: publish / subscribe
|
|
36
|
+
- `http`: publish / subscribe(webhook)
|
|
37
|
+
- `kafka`: publish / subscribe
|
|
38
|
+
- `redis`: publish / subscribe
|
|
39
|
+
|
|
40
|
+
註:`kafka` 與 `redis` 目前已提供 client/config 與文件、測試腳本骨架;若要實際連線驗證,需安裝對應套件並啟動外部服務。
|
|
41
|
+
|
|
42
|
+
## 安裝(本機)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd /home/allen/Documents/project/asia-bay/SmartWarehousing/test/smart_messaging_core
|
|
46
|
+
pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 文件
|
|
50
|
+
|
|
51
|
+
- [通訊介面與使用範例](docs/README.md)
|
|
52
|
+
- [MQTT 使用說明](docs/mqtt/README.md)
|
|
53
|
+
- [HTTP 使用說明](docs/http/README.md)
|
|
54
|
+
- [Kafka 使用說明](docs/kafka/README.md)
|
|
55
|
+
- [Redis 使用說明](docs/redis/README.md)
|
|
56
|
+
|
|
57
|
+
## 設計說明
|
|
58
|
+
|
|
59
|
+
- 對外 API:`MessagingClient`
|
|
60
|
+
- 協議實作集中在 `protocols/`
|
|
61
|
+
- 每個協議提供對應 `Config` 與 `Client`
|
|
62
|
+
- route-based 設計可讓不同用途走不同 backend/channel
|
|
63
|
+
- 錯誤處理可透過 `failure_mode` 控制(`degraded` 只 log;`strict` 會 raise)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# smart_messaging_core
|
|
2
|
+
|
|
3
|
+
可擴充的通訊核心套件,提供統一的 `MessagingClient` facade,並可依 route 將訊息導向不同協議。
|
|
4
|
+
|
|
5
|
+
## 目前支援的協議
|
|
6
|
+
|
|
7
|
+
- `mqtt`: publish / subscribe
|
|
8
|
+
- `http`: publish / subscribe(webhook)
|
|
9
|
+
- `kafka`: publish / subscribe
|
|
10
|
+
- `redis`: publish / subscribe
|
|
11
|
+
|
|
12
|
+
註:`kafka` 與 `redis` 目前已提供 client/config 與文件、測試腳本骨架;若要實際連線驗證,需安裝對應套件並啟動外部服務。
|
|
13
|
+
|
|
14
|
+
## 安裝(本機)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd /home/allen/Documents/project/asia-bay/SmartWarehousing/test/smart_messaging_core
|
|
18
|
+
pip install -e .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 文件
|
|
22
|
+
|
|
23
|
+
- [通訊介面與使用範例](docs/README.md)
|
|
24
|
+
- [MQTT 使用說明](docs/mqtt/README.md)
|
|
25
|
+
- [HTTP 使用說明](docs/http/README.md)
|
|
26
|
+
- [Kafka 使用說明](docs/kafka/README.md)
|
|
27
|
+
- [Redis 使用說明](docs/redis/README.md)
|
|
28
|
+
|
|
29
|
+
## 設計說明
|
|
30
|
+
|
|
31
|
+
- 對外 API:`MessagingClient`
|
|
32
|
+
- 協議實作集中在 `protocols/`
|
|
33
|
+
- 每個協議提供對應 `Config` 與 `Client`
|
|
34
|
+
- route-based 設計可讓不同用途走不同 backend/channel
|
|
35
|
+
- 錯誤處理可透過 `failure_mode` 控制(`degraded` 只 log;`strict` 會 raise)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "smart-messaging-core"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Messaging helpers for edge/integration services."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "SmartWarehousing Team" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["messaging", "mqtt", "http", "kafka", "redis"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
"Topic :: System :: Networking",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"paho-mqtt>=1.6.1",
|
|
23
|
+
"kafka-python>=2.0.2",
|
|
24
|
+
"redis>=5.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/allenshie/smart_messaging_core"
|
|
29
|
+
Repository = "https://github.com/allenshie/smart_messaging_core"
|
|
30
|
+
Issues = "https://github.com/allenshie/smart_messaging_core/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"build>=1.2.2",
|
|
35
|
+
"twine>=5.1.1",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["setuptools>=68", "wheel"]
|
|
40
|
+
build-backend = "setuptools.build_meta"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools]
|
|
43
|
+
package-dir = {"" = "src"}
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Public exports for smart_messaging_core."""
|
|
2
|
+
from .base import BaseProtocolClient, BaseProtocolConfig
|
|
3
|
+
from .client import MessagingClient, MessagingConfig, RouteConfig
|
|
4
|
+
from .protocols import (
|
|
5
|
+
HttpClient,
|
|
6
|
+
HttpConfig,
|
|
7
|
+
HttpPublisher,
|
|
8
|
+
KafkaClient,
|
|
9
|
+
KafkaConfig,
|
|
10
|
+
MqttClient,
|
|
11
|
+
MqttConfig,
|
|
12
|
+
MqttPublisher,
|
|
13
|
+
MqttSubscriber,
|
|
14
|
+
RedisClient,
|
|
15
|
+
RedisConfig,
|
|
16
|
+
)
|
|
17
|
+
from .registry import CLIENTS
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BaseProtocolClient",
|
|
21
|
+
"BaseProtocolConfig",
|
|
22
|
+
"MessagingClient",
|
|
23
|
+
"MessagingConfig",
|
|
24
|
+
"RouteConfig",
|
|
25
|
+
"HttpClient",
|
|
26
|
+
"HttpConfig",
|
|
27
|
+
"HttpPublisher",
|
|
28
|
+
"KafkaClient",
|
|
29
|
+
"KafkaConfig",
|
|
30
|
+
"MqttClient",
|
|
31
|
+
"MqttConfig",
|
|
32
|
+
"MqttPublisher",
|
|
33
|
+
"MqttSubscriber",
|
|
34
|
+
"RedisClient",
|
|
35
|
+
"RedisConfig",
|
|
36
|
+
"CLIENTS",
|
|
37
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Base protocol abstractions for smart_messaging_core."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from abc import ABC
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class BaseProtocolConfig:
|
|
11
|
+
"""Base configuration for a messaging protocol client."""
|
|
12
|
+
|
|
13
|
+
enabled: bool = True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseProtocolClient(ABC):
|
|
17
|
+
"""Unified protocol client interface used by MessagingClient."""
|
|
18
|
+
|
|
19
|
+
supports_publish: bool = False
|
|
20
|
+
supports_subscribe: bool = False
|
|
21
|
+
config_type = BaseProtocolConfig
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: BaseProtocolConfig) -> None:
|
|
24
|
+
self._validate_config(config)
|
|
25
|
+
self._config = config
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def _validate_config(cls, config: BaseProtocolConfig) -> None:
|
|
29
|
+
if not isinstance(config, cls.config_type):
|
|
30
|
+
raise TypeError(
|
|
31
|
+
f"{cls.__name__} requires {cls.config_type.__name__}, got {type(config).__name__}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def publish(self, channel: str, payload: dict[str, Any]) -> bool:
|
|
35
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not support publish")
|
|
36
|
+
|
|
37
|
+
def subscribe(self, channel: str, handler: Callable[[dict[str, Any]], None]) -> None:
|
|
38
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not support subscribe")
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
"""Release protocol resources; default is no-op."""
|
|
42
|
+
return None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Route-based unified messaging client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from smart_messaging_core.protocols.http import HttpConfig
|
|
9
|
+
from smart_messaging_core.protocols.kafka import KafkaConfig
|
|
10
|
+
from smart_messaging_core.protocols.mqtt import MqttConfig
|
|
11
|
+
from smart_messaging_core.protocols.redis import RedisConfig
|
|
12
|
+
from smart_messaging_core.registry import CLIENTS
|
|
13
|
+
|
|
14
|
+
LOGGER = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RouteConfig:
|
|
19
|
+
backend: str
|
|
20
|
+
channel: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class MessagingConfig:
|
|
25
|
+
failure_mode: str = "degraded"
|
|
26
|
+
mqtt: MqttConfig = field(default_factory=MqttConfig)
|
|
27
|
+
http: HttpConfig | None = None
|
|
28
|
+
kafka: KafkaConfig | None = None
|
|
29
|
+
redis: RedisConfig | None = None
|
|
30
|
+
routes: dict[str, RouteConfig] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MessagingClient:
|
|
34
|
+
"""Facade that routes publish/subscribe calls to protocol-specific clients."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, config: MessagingConfig) -> None:
|
|
37
|
+
self._config = config
|
|
38
|
+
self._clients: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
def publish(self, route_key: str, payload: dict[str, Any]) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
backend, channel = self._resolve_route(route_key)
|
|
43
|
+
client = self._get_client(backend)
|
|
44
|
+
if not getattr(client, "supports_publish", False):
|
|
45
|
+
raise ValueError(f"Backend {backend} does not support publish")
|
|
46
|
+
return client.publish(channel, payload)
|
|
47
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
48
|
+
self._handle_error("publish", route_key, exc)
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def subscribe(self, route_key: str, handler: Callable[[dict[str, Any]], None]) -> None:
|
|
52
|
+
try:
|
|
53
|
+
backend, channel = self._resolve_route(route_key)
|
|
54
|
+
client = self._get_client(backend)
|
|
55
|
+
if not getattr(client, "supports_subscribe", False):
|
|
56
|
+
raise ValueError(f"Backend {backend} does not support subscribe")
|
|
57
|
+
client.subscribe(channel, handler)
|
|
58
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
59
|
+
self._handle_error("subscribe", route_key, exc)
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
for backend, client in list(self._clients.items()):
|
|
63
|
+
try:
|
|
64
|
+
close_fn = getattr(client, "close", None)
|
|
65
|
+
if callable(close_fn):
|
|
66
|
+
close_fn()
|
|
67
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
68
|
+
LOGGER.warning("Messaging close failed (backend=%s): %s", backend, exc)
|
|
69
|
+
self._clients.clear()
|
|
70
|
+
|
|
71
|
+
def _resolve_route(self, route_key: str) -> tuple[str, str]:
|
|
72
|
+
route = self._config.routes.get(route_key)
|
|
73
|
+
if route is None:
|
|
74
|
+
raise ValueError(f"Unknown route: {route_key}")
|
|
75
|
+
return route.backend, route.channel
|
|
76
|
+
|
|
77
|
+
def _get_client(self, backend: str):
|
|
78
|
+
key = (backend or "").strip().lower()
|
|
79
|
+
client = self._clients.get(key)
|
|
80
|
+
if client is not None:
|
|
81
|
+
return client
|
|
82
|
+
entry = CLIENTS.get(key)
|
|
83
|
+
if entry is None:
|
|
84
|
+
raise ValueError(f"Unsupported backend: {backend}")
|
|
85
|
+
config_attr, config_cls, client_cls = entry
|
|
86
|
+
cfg = getattr(self._config, config_attr, None)
|
|
87
|
+
if cfg is None:
|
|
88
|
+
raise ValueError(f"Missing config for backend: {backend}")
|
|
89
|
+
if not isinstance(cfg, config_cls):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"Config for backend {backend} must be {config_cls.__name__}, got {type(cfg).__name__}"
|
|
92
|
+
)
|
|
93
|
+
client = client_cls(cfg)
|
|
94
|
+
self._clients[key] = client
|
|
95
|
+
return client
|
|
96
|
+
|
|
97
|
+
def _handle_error(self, action: str, route_key: str, exc: Exception) -> None:
|
|
98
|
+
mode = (self._config.failure_mode or "degraded").strip().lower()
|
|
99
|
+
if mode == "strict":
|
|
100
|
+
raise exc
|
|
101
|
+
LOGGER.warning("Messaging %s failed (route=%s, mode=%s): %s", action, route_key, mode, exc)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Protocol exports for smart_messaging_core."""
|
|
2
|
+
from .http import HttpClient, HttpConfig, HttpPublisher
|
|
3
|
+
from .kafka import KafkaClient, KafkaConfig
|
|
4
|
+
from .mqtt import MqttClient, MqttConfig, MqttPublisher, MqttSubscriber
|
|
5
|
+
from .redis import RedisClient, RedisConfig
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"HttpClient",
|
|
9
|
+
"HttpConfig",
|
|
10
|
+
"HttpPublisher",
|
|
11
|
+
"KafkaClient",
|
|
12
|
+
"KafkaConfig",
|
|
13
|
+
"MqttClient",
|
|
14
|
+
"MqttConfig",
|
|
15
|
+
"MqttPublisher",
|
|
16
|
+
"MqttSubscriber",
|
|
17
|
+
"RedisClient",
|
|
18
|
+
"RedisConfig",
|
|
19
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""HTTP protocol client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from http import HTTPStatus
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from smart_messaging_core.base import BaseProtocolClient, BaseProtocolConfig
|
|
15
|
+
|
|
16
|
+
LOGGER = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class HttpConfig(BaseProtocolConfig):
|
|
21
|
+
base_url: str = ""
|
|
22
|
+
timeout_seconds: int = 5
|
|
23
|
+
listen_host: str = "0.0.0.0"
|
|
24
|
+
listen_port: int = 9000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpClient(BaseProtocolClient):
|
|
28
|
+
"""HTTP client supporting publish + webhook subscribe."""
|
|
29
|
+
|
|
30
|
+
supports_publish = True
|
|
31
|
+
supports_subscribe = True
|
|
32
|
+
config_type = HttpConfig
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: HttpConfig) -> None:
|
|
35
|
+
super().__init__(config)
|
|
36
|
+
self._server: ThreadingHTTPServer | None = None
|
|
37
|
+
self._server_thread: threading.Thread | None = None
|
|
38
|
+
self._handlers: dict[str, Callable[[dict[str, Any]], None]] = {}
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def listening_port(self) -> int:
|
|
42
|
+
if self._server is None:
|
|
43
|
+
return self._config.listen_port
|
|
44
|
+
return int(self._server.server_address[1])
|
|
45
|
+
|
|
46
|
+
def publish(self, channel: str, payload: dict[str, Any]) -> bool:
|
|
47
|
+
if not self._config.base_url:
|
|
48
|
+
LOGGER.warning("HTTP publish failed: base_url is empty")
|
|
49
|
+
return False
|
|
50
|
+
path = channel if channel.startswith("/") else f"/{channel}"
|
|
51
|
+
url = f"{self._config.base_url.rstrip('/')}{path}"
|
|
52
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
53
|
+
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=self._config.timeout_seconds):
|
|
56
|
+
return True
|
|
57
|
+
except urllib.error.URLError as exc:
|
|
58
|
+
LOGGER.warning("HTTP publish failed (%s): %s", url, exc)
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def subscribe(self, channel: str, handler: Callable[[dict[str, Any]], None]) -> None:
|
|
62
|
+
path = channel if channel.startswith("/") else f"/{channel}"
|
|
63
|
+
self._handlers[path] = handler
|
|
64
|
+
self._ensure_server()
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
if self._server is None:
|
|
68
|
+
return
|
|
69
|
+
self._server.shutdown()
|
|
70
|
+
self._server.server_close()
|
|
71
|
+
if self._server_thread is not None:
|
|
72
|
+
self._server_thread.join(timeout=2)
|
|
73
|
+
self._server = None
|
|
74
|
+
self._server_thread = None
|
|
75
|
+
|
|
76
|
+
def _ensure_server(self) -> None:
|
|
77
|
+
if self._server is not None:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
owner = self
|
|
81
|
+
|
|
82
|
+
class _WebhookHandler(BaseHTTPRequestHandler):
|
|
83
|
+
def do_POST(self): # noqa: N802
|
|
84
|
+
callback = owner._handlers.get(self.path)
|
|
85
|
+
if callback is None:
|
|
86
|
+
self.send_error(HTTPStatus.NOT_FOUND, "unknown path")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
90
|
+
body = self.rfile.read(length)
|
|
91
|
+
try:
|
|
92
|
+
payload = json.loads(body.decode("utf-8"))
|
|
93
|
+
if not isinstance(payload, dict):
|
|
94
|
+
raise ValueError("payload must be JSON object")
|
|
95
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
96
|
+
LOGGER.warning("HTTP subscribe invalid payload (%s): %s", self.path, exc)
|
|
97
|
+
self.send_error(HTTPStatus.BAD_REQUEST, f"invalid payload: {exc}")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
callback(payload)
|
|
102
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
103
|
+
LOGGER.warning("HTTP subscribe callback failed (%s): %s", self.path, exc)
|
|
104
|
+
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "callback failed")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
self.send_response(HTTPStatus.ACCEPTED)
|
|
108
|
+
self.end_headers()
|
|
109
|
+
|
|
110
|
+
def log_message(self, format, *args): # noqa: A003, ANN001
|
|
111
|
+
LOGGER.debug("http-sub %s - %s", self.address_string(), format % args)
|
|
112
|
+
|
|
113
|
+
self._server = ThreadingHTTPServer(
|
|
114
|
+
(self._config.listen_host, self._config.listen_port),
|
|
115
|
+
_WebhookHandler,
|
|
116
|
+
)
|
|
117
|
+
self._server_thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
118
|
+
self._server_thread.start()
|
|
119
|
+
LOGGER.info(
|
|
120
|
+
"HTTP subscribe server listening on %s:%s",
|
|
121
|
+
self._config.listen_host,
|
|
122
|
+
self.listening_port,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class HttpPublisher:
|
|
127
|
+
"""Compatibility wrapper for the legacy publish-only HTTP API."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, config: HttpConfig, topic_routes: dict[str, str] | None = None) -> None:
|
|
130
|
+
self._client = HttpClient(config)
|
|
131
|
+
self._topic_routes = topic_routes or {}
|
|
132
|
+
|
|
133
|
+
def publish(self, topic: str, payload: dict[str, Any]) -> bool:
|
|
134
|
+
channel = self._topic_routes.get(topic, topic)
|
|
135
|
+
return self._client.publish(channel, payload)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Kafka protocol client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from smart_messaging_core.base import BaseProtocolClient, BaseProtocolConfig
|
|
10
|
+
|
|
11
|
+
LOGGER = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class KafkaConfig(BaseProtocolConfig):
|
|
16
|
+
bootstrap_servers: str = "localhost:9092"
|
|
17
|
+
client_id: str | None = None
|
|
18
|
+
group_id: str | None = None
|
|
19
|
+
auto_offset_reset: str = "latest"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class KafkaClient(BaseProtocolClient):
|
|
23
|
+
"""Kafka client supporting publish and subscribe."""
|
|
24
|
+
|
|
25
|
+
supports_publish = True
|
|
26
|
+
supports_subscribe = True
|
|
27
|
+
config_type = KafkaConfig
|
|
28
|
+
|
|
29
|
+
def publish(self, channel: str, payload: dict[str, Any]) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
from kafka import KafkaProducer # type: ignore
|
|
32
|
+
except ModuleNotFoundError as exc:
|
|
33
|
+
raise RuntimeError("Missing dependency: kafka-python") from exc
|
|
34
|
+
producer = KafkaProducer(
|
|
35
|
+
bootstrap_servers=self._config.bootstrap_servers,
|
|
36
|
+
client_id=self._config.client_id,
|
|
37
|
+
value_serializer=lambda value: json.dumps(value, ensure_ascii=False).encode("utf-8"),
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
producer.send(channel, payload).get(timeout=5)
|
|
41
|
+
return True
|
|
42
|
+
finally:
|
|
43
|
+
producer.close()
|
|
44
|
+
|
|
45
|
+
def subscribe(self, channel: str, handler: Callable[[dict[str, Any]], None]) -> None:
|
|
46
|
+
try:
|
|
47
|
+
from kafka import KafkaConsumer # type: ignore
|
|
48
|
+
except ModuleNotFoundError as exc:
|
|
49
|
+
raise RuntimeError("Missing dependency: kafka-python") from exc
|
|
50
|
+
consumer = KafkaConsumer(
|
|
51
|
+
channel,
|
|
52
|
+
bootstrap_servers=self._config.bootstrap_servers,
|
|
53
|
+
client_id=self._config.client_id,
|
|
54
|
+
group_id=self._config.group_id,
|
|
55
|
+
auto_offset_reset=self._config.auto_offset_reset,
|
|
56
|
+
value_deserializer=lambda value: json.loads(value.decode("utf-8") or "{}"),
|
|
57
|
+
)
|
|
58
|
+
for message in consumer:
|
|
59
|
+
if isinstance(message.value, dict):
|
|
60
|
+
handler(message.value)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""MQTT protocol client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from smart_messaging_core.base import BaseProtocolClient, BaseProtocolConfig
|
|
10
|
+
|
|
11
|
+
LOGGER = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MqttConfig(BaseProtocolConfig):
|
|
16
|
+
host: str = "localhost"
|
|
17
|
+
port: int = 1883
|
|
18
|
+
qos: int = 1
|
|
19
|
+
retain: bool = True
|
|
20
|
+
client_id: str | None = None
|
|
21
|
+
keepalive: int = 60
|
|
22
|
+
auth_enabled: bool = False
|
|
23
|
+
username: str | None = None
|
|
24
|
+
password: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MqttClient(BaseProtocolClient):
|
|
28
|
+
"""MQTT client supporting both publish and subscribe."""
|
|
29
|
+
|
|
30
|
+
supports_publish = True
|
|
31
|
+
supports_subscribe = True
|
|
32
|
+
config_type = MqttConfig
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: MqttConfig) -> None:
|
|
35
|
+
super().__init__(config)
|
|
36
|
+
self._client = None
|
|
37
|
+
self._subscriptions: set[str] = set()
|
|
38
|
+
|
|
39
|
+
def publish(self, channel: str, payload: dict[str, object]) -> bool:
|
|
40
|
+
try:
|
|
41
|
+
client = self._ensure_client(connect_async=False)
|
|
42
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
43
|
+
LOGGER.warning("MQTT publish client init failed: %s", exc)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
message = json.dumps(payload, ensure_ascii=False)
|
|
47
|
+
result = client.publish(channel, message, qos=self._config.qos, retain=self._config.retain)
|
|
48
|
+
if result.rc != 0:
|
|
49
|
+
LOGGER.warning("MQTT publish failed (rc=%s)", result.rc)
|
|
50
|
+
return False
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
def subscribe(self, channel: str, handler: Callable[[dict[str, object]], None]) -> None:
|
|
54
|
+
try:
|
|
55
|
+
client = self._ensure_client(connect_async=True)
|
|
56
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
57
|
+
LOGGER.warning("MQTT subscribe client init failed: %s", exc)
|
|
58
|
+
return
|
|
59
|
+
client.message_callback_add(channel, self._build_message_handler(handler))
|
|
60
|
+
self._subscriptions.add(channel)
|
|
61
|
+
if getattr(client, "is_connected", lambda: False)():
|
|
62
|
+
client.subscribe(channel, qos=self._config.qos)
|
|
63
|
+
|
|
64
|
+
def _ensure_client(self, connect_async: bool):
|
|
65
|
+
if self._client is not None:
|
|
66
|
+
return self._client
|
|
67
|
+
try:
|
|
68
|
+
import paho.mqtt.client as mqtt # type: ignore
|
|
69
|
+
except ModuleNotFoundError as exc:
|
|
70
|
+
raise RuntimeError("Missing dependency: paho-mqtt") from exc
|
|
71
|
+
client = mqtt.Client(client_id=self._config.client_id)
|
|
72
|
+
_apply_auth(client, self._config)
|
|
73
|
+
client.on_connect = self._handle_connect
|
|
74
|
+
if connect_async:
|
|
75
|
+
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
|
76
|
+
client.connect_async(self._config.host, self._config.port, keepalive=self._config.keepalive)
|
|
77
|
+
client.loop_start()
|
|
78
|
+
else:
|
|
79
|
+
client.connect(self._config.host, self._config.port, keepalive=self._config.keepalive)
|
|
80
|
+
client.loop_start()
|
|
81
|
+
self._client = client
|
|
82
|
+
return client
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
if self._client is None:
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
self._client.loop_stop()
|
|
90
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
91
|
+
LOGGER.debug("MQTT loop_stop failed: %s", exc)
|
|
92
|
+
try:
|
|
93
|
+
self._client.disconnect()
|
|
94
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
95
|
+
LOGGER.debug("MQTT disconnect failed: %s", exc)
|
|
96
|
+
self._client = None
|
|
97
|
+
|
|
98
|
+
def _handle_connect(self, client, userdata, flags, rc): # noqa: ANN001
|
|
99
|
+
_ = (userdata, flags)
|
|
100
|
+
if rc != 0:
|
|
101
|
+
LOGGER.warning("MQTT connect failed (rc=%s)", rc)
|
|
102
|
+
return
|
|
103
|
+
for channel in self._subscriptions:
|
|
104
|
+
client.subscribe(channel, qos=self._config.qos)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _build_message_handler(handler: Callable[[dict[str, object]], None]):
|
|
108
|
+
def _handle_message(client, userdata, msg): # noqa: ANN001
|
|
109
|
+
_ = (client, userdata)
|
|
110
|
+
try:
|
|
111
|
+
payload = json.loads(msg.payload.decode("utf-8") or "{}")
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
LOGGER.debug("MQTT message ignored: invalid json")
|
|
114
|
+
return
|
|
115
|
+
if isinstance(payload, dict):
|
|
116
|
+
handler(payload)
|
|
117
|
+
|
|
118
|
+
return _handle_message
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class MqttPublisher:
|
|
122
|
+
"""Compatibility wrapper for the legacy MQTT publish API."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, config: MqttConfig, topic: str) -> None:
|
|
125
|
+
self._client = MqttClient(config)
|
|
126
|
+
self._topic = topic
|
|
127
|
+
|
|
128
|
+
def publish(self, payload: dict[str, object]) -> bool:
|
|
129
|
+
return self._client.publish(self._topic, payload)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class MqttSubscriber:
|
|
133
|
+
"""Compatibility wrapper for the legacy MQTT subscribe API."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
config: MqttConfig,
|
|
138
|
+
topic: str,
|
|
139
|
+
on_message: Callable[[dict[str, object]], None],
|
|
140
|
+
) -> None:
|
|
141
|
+
self._client = MqttClient(config)
|
|
142
|
+
self._topic = topic
|
|
143
|
+
self._on_message = on_message
|
|
144
|
+
|
|
145
|
+
def start(self) -> None:
|
|
146
|
+
self._client.subscribe(self._topic, self._on_message)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _apply_auth(client, config: MqttConfig) -> None: # noqa: ANN001
|
|
150
|
+
if not config.auth_enabled:
|
|
151
|
+
return
|
|
152
|
+
if not config.username:
|
|
153
|
+
raise ValueError("mqtt auth enabled but username is empty")
|
|
154
|
+
client.username_pw_set(config.username, config.password)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Redis protocol client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from smart_messaging_core.base import BaseProtocolClient, BaseProtocolConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class RedisConfig(BaseProtocolConfig):
|
|
13
|
+
host: str = "localhost"
|
|
14
|
+
port: int = 6379
|
|
15
|
+
db: int = 0
|
|
16
|
+
username: str | None = None
|
|
17
|
+
password: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RedisClient(BaseProtocolClient):
|
|
21
|
+
"""Redis pub/sub client."""
|
|
22
|
+
|
|
23
|
+
supports_publish = True
|
|
24
|
+
supports_subscribe = True
|
|
25
|
+
config_type = RedisConfig
|
|
26
|
+
|
|
27
|
+
def publish(self, channel: str, payload: dict[str, object]) -> bool:
|
|
28
|
+
client = self._build_client()
|
|
29
|
+
return client.publish(channel, json.dumps(payload, ensure_ascii=False)) > 0
|
|
30
|
+
|
|
31
|
+
def subscribe(self, channel: str, handler: Callable[[dict[str, object]], None]) -> None:
|
|
32
|
+
client = self._build_client()
|
|
33
|
+
pubsub = client.pubsub()
|
|
34
|
+
pubsub.subscribe(channel)
|
|
35
|
+
for message in pubsub.listen():
|
|
36
|
+
if message.get("type") != "message":
|
|
37
|
+
continue
|
|
38
|
+
data = message.get("data")
|
|
39
|
+
if isinstance(data, bytes):
|
|
40
|
+
payload = json.loads(data.decode("utf-8") or "{}")
|
|
41
|
+
if isinstance(payload, dict):
|
|
42
|
+
handler(payload)
|
|
43
|
+
|
|
44
|
+
def _build_client(self):
|
|
45
|
+
try:
|
|
46
|
+
import redis # type: ignore
|
|
47
|
+
except ModuleNotFoundError as exc:
|
|
48
|
+
raise RuntimeError("Missing dependency: redis") from exc
|
|
49
|
+
return redis.Redis(
|
|
50
|
+
host=self._config.host,
|
|
51
|
+
port=self._config.port,
|
|
52
|
+
db=self._config.db,
|
|
53
|
+
username=self._config.username,
|
|
54
|
+
password=self._config.password,
|
|
55
|
+
decode_responses=False,
|
|
56
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Protocol registry for smart_messaging_core."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from smart_messaging_core.protocols.http import HttpClient, HttpConfig
|
|
7
|
+
from smart_messaging_core.protocols.kafka import KafkaClient, KafkaConfig
|
|
8
|
+
from smart_messaging_core.protocols.mqtt import MqttClient, MqttConfig
|
|
9
|
+
from smart_messaging_core.protocols.redis import RedisClient, RedisConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CLIENTS: dict[str, tuple[str, type[Any], type[Any]]] = {
|
|
13
|
+
"mqtt": ("mqtt", MqttConfig, MqttClient),
|
|
14
|
+
"http": ("http", HttpConfig, HttpClient),
|
|
15
|
+
"kafka": ("kafka", KafkaConfig, KafkaClient),
|
|
16
|
+
"redis": ("redis", RedisConfig, RedisClient),
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smart-messaging-core
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Messaging helpers for edge/integration services.
|
|
5
|
+
Author: SmartWarehousing Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/allenshie/smart_messaging_core
|
|
8
|
+
Project-URL: Repository, https://github.com/allenshie/smart_messaging_core
|
|
9
|
+
Project-URL: Issues, https://github.com/allenshie/smart_messaging_core/issues
|
|
10
|
+
Keywords: messaging,mqtt,http,kafka,redis
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: System :: Networking
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: paho-mqtt>=1.6.1
|
|
22
|
+
Requires-Dist: kafka-python>=2.0.2
|
|
23
|
+
Requires-Dist: redis>=5.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2.2; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=5.1.1; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# smart_messaging_core
|
|
30
|
+
|
|
31
|
+
可擴充的通訊核心套件,提供統一的 `MessagingClient` facade,並可依 route 將訊息導向不同協議。
|
|
32
|
+
|
|
33
|
+
## 目前支援的協議
|
|
34
|
+
|
|
35
|
+
- `mqtt`: publish / subscribe
|
|
36
|
+
- `http`: publish / subscribe(webhook)
|
|
37
|
+
- `kafka`: publish / subscribe
|
|
38
|
+
- `redis`: publish / subscribe
|
|
39
|
+
|
|
40
|
+
註:`kafka` 與 `redis` 目前已提供 client/config 與文件、測試腳本骨架;若要實際連線驗證,需安裝對應套件並啟動外部服務。
|
|
41
|
+
|
|
42
|
+
## 安裝(本機)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd /home/allen/Documents/project/asia-bay/SmartWarehousing/test/smart_messaging_core
|
|
46
|
+
pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 文件
|
|
50
|
+
|
|
51
|
+
- [通訊介面與使用範例](docs/README.md)
|
|
52
|
+
- [MQTT 使用說明](docs/mqtt/README.md)
|
|
53
|
+
- [HTTP 使用說明](docs/http/README.md)
|
|
54
|
+
- [Kafka 使用說明](docs/kafka/README.md)
|
|
55
|
+
- [Redis 使用說明](docs/redis/README.md)
|
|
56
|
+
|
|
57
|
+
## 設計說明
|
|
58
|
+
|
|
59
|
+
- 對外 API:`MessagingClient`
|
|
60
|
+
- 協議實作集中在 `protocols/`
|
|
61
|
+
- 每個協議提供對應 `Config` 與 `Client`
|
|
62
|
+
- route-based 設計可讓不同用途走不同 backend/channel
|
|
63
|
+
- 錯誤處理可透過 `failure_mode` 控制(`degraded` 只 log;`strict` 會 raise)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/smart_messaging_core/__init__.py
|
|
5
|
+
src/smart_messaging_core/base.py
|
|
6
|
+
src/smart_messaging_core/client.py
|
|
7
|
+
src/smart_messaging_core/registry.py
|
|
8
|
+
src/smart_messaging_core.egg-info/PKG-INFO
|
|
9
|
+
src/smart_messaging_core.egg-info/SOURCES.txt
|
|
10
|
+
src/smart_messaging_core.egg-info/dependency_links.txt
|
|
11
|
+
src/smart_messaging_core.egg-info/requires.txt
|
|
12
|
+
src/smart_messaging_core.egg-info/top_level.txt
|
|
13
|
+
src/smart_messaging_core/protocols/__init__.py
|
|
14
|
+
src/smart_messaging_core/protocols/http.py
|
|
15
|
+
src/smart_messaging_core/protocols/kafka.py
|
|
16
|
+
src/smart_messaging_core/protocols/mqtt.py
|
|
17
|
+
src/smart_messaging_core/protocols/redis.py
|
|
18
|
+
tests/test_client_routes.py
|
|
19
|
+
tests/test_imports.py
|
|
20
|
+
tests/test_protocol_validation.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smart_messaging_core
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from unittest import TestCase
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from smart_messaging_core.base import BaseProtocolClient, BaseProtocolConfig
|
|
6
|
+
from smart_messaging_core.client import MessagingClient, MessagingConfig, RouteConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DummyConfig(BaseProtocolConfig):
|
|
11
|
+
value: str = "dummy"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DummyClient(BaseProtocolClient):
|
|
15
|
+
supports_publish = True
|
|
16
|
+
supports_subscribe = True
|
|
17
|
+
config_type = DummyConfig
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: DummyConfig) -> None:
|
|
20
|
+
super().__init__(config)
|
|
21
|
+
self.published: list[tuple[str, dict]] = []
|
|
22
|
+
self.subscribed: list[str] = []
|
|
23
|
+
self.closed = False
|
|
24
|
+
|
|
25
|
+
def publish(self, channel: str, payload: dict) -> bool:
|
|
26
|
+
self.published.append((channel, payload))
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
def subscribe(self, channel: str, handler) -> None: # noqa: ANN001
|
|
30
|
+
self.subscribed.append(channel)
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
self.closed = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MessagingClientRouteTests(TestCase):
|
|
37
|
+
def test_routes_publish_and_subscribe(self) -> None:
|
|
38
|
+
with patch(
|
|
39
|
+
"smart_messaging_core.client.CLIENTS",
|
|
40
|
+
{"dummy": ("dummy", DummyConfig, DummyClient)},
|
|
41
|
+
):
|
|
42
|
+
cfg = MessagingConfig(
|
|
43
|
+
routes={
|
|
44
|
+
"phase_publish": RouteConfig(backend="dummy", channel="integration/phase"),
|
|
45
|
+
"edge_events": RouteConfig(backend="dummy", channel="edge/events"),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
cfg.dummy = DummyConfig() # type: ignore[attr-defined]
|
|
49
|
+
client = MessagingClient(cfg)
|
|
50
|
+
|
|
51
|
+
self.assertTrue(client.publish("phase_publish", {"phase": "working"}))
|
|
52
|
+
client.subscribe("edge_events", lambda payload: payload)
|
|
53
|
+
|
|
54
|
+
dummy = client._clients["dummy"] # noqa: SLF001
|
|
55
|
+
self.assertEqual(dummy.published, [("integration/phase", {"phase": "working"})])
|
|
56
|
+
self.assertEqual(dummy.subscribed, ["edge/events"])
|
|
57
|
+
|
|
58
|
+
def test_close_closes_all_created_protocol_clients(self) -> None:
|
|
59
|
+
with patch(
|
|
60
|
+
"smart_messaging_core.client.CLIENTS",
|
|
61
|
+
{"dummy": ("dummy", DummyConfig, DummyClient)},
|
|
62
|
+
):
|
|
63
|
+
cfg = MessagingConfig(routes={"phase_publish": RouteConfig(backend="dummy", channel="phase")})
|
|
64
|
+
cfg.dummy = DummyConfig() # type: ignore[attr-defined]
|
|
65
|
+
client = MessagingClient(cfg)
|
|
66
|
+
client.publish("phase_publish", {"x": 1})
|
|
67
|
+
|
|
68
|
+
dummy = client._clients["dummy"] # noqa: SLF001
|
|
69
|
+
self.assertFalse(dummy.closed)
|
|
70
|
+
client.close()
|
|
71
|
+
self.assertTrue(dummy.closed)
|
|
72
|
+
self.assertEqual(client._clients, {}) # noqa: SLF001
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
def test_imports() -> None:
|
|
2
|
+
from smart_messaging_core import ( # noqa: F401
|
|
3
|
+
BaseProtocolClient,
|
|
4
|
+
BaseProtocolConfig,
|
|
5
|
+
CLIENTS,
|
|
6
|
+
HttpClient,
|
|
7
|
+
HttpConfig,
|
|
8
|
+
HttpPublisher,
|
|
9
|
+
KafkaClient,
|
|
10
|
+
KafkaConfig,
|
|
11
|
+
MessagingClient,
|
|
12
|
+
MessagingConfig,
|
|
13
|
+
MqttClient,
|
|
14
|
+
MqttConfig,
|
|
15
|
+
MqttPublisher,
|
|
16
|
+
MqttSubscriber,
|
|
17
|
+
RedisClient,
|
|
18
|
+
RedisConfig,
|
|
19
|
+
RouteConfig,
|
|
20
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import urllib.request
|
|
4
|
+
from unittest import TestCase
|
|
5
|
+
|
|
6
|
+
from smart_messaging_core.base import BaseProtocolConfig
|
|
7
|
+
from smart_messaging_core.client import MessagingClient, MessagingConfig, RouteConfig
|
|
8
|
+
from smart_messaging_core.protocols.http import HttpClient, HttpConfig
|
|
9
|
+
from smart_messaging_core.protocols.mqtt import MqttClient, MqttConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WrongConfig(BaseProtocolConfig):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProtocolValidationTests(TestCase):
|
|
17
|
+
def test_mqtt_client_rejects_wrong_config_type(self) -> None:
|
|
18
|
+
with self.assertRaises(TypeError):
|
|
19
|
+
MqttClient(WrongConfig()) # type: ignore[arg-type]
|
|
20
|
+
|
|
21
|
+
def test_http_client_rejects_wrong_config_type(self) -> None:
|
|
22
|
+
with self.assertRaises(TypeError):
|
|
23
|
+
HttpClient(WrongConfig()) # type: ignore[arg-type]
|
|
24
|
+
|
|
25
|
+
def test_messaging_client_strict_mode_raises_for_unsupported_backend(self) -> None:
|
|
26
|
+
client = MessagingClient(
|
|
27
|
+
MessagingConfig(
|
|
28
|
+
failure_mode="strict",
|
|
29
|
+
mqtt=MqttConfig(),
|
|
30
|
+
http=HttpConfig(base_url="http://localhost:8080"),
|
|
31
|
+
routes={"phase_publish": RouteConfig(backend="unsupported", channel="/phase")},
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
with self.assertRaises(ValueError):
|
|
35
|
+
client.publish("phase_publish", {"x": 1})
|
|
36
|
+
|
|
37
|
+
def test_messaging_client_strict_mode_raises_for_unknown_route(self) -> None:
|
|
38
|
+
client = MessagingClient(
|
|
39
|
+
MessagingConfig(
|
|
40
|
+
failure_mode="strict",
|
|
41
|
+
mqtt=MqttConfig(),
|
|
42
|
+
routes={"edge_events": RouteConfig(backend="mqtt", channel="edge/events")},
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
with self.assertRaises(ValueError):
|
|
46
|
+
client.publish("unknown_route", {"x": 1})
|
|
47
|
+
|
|
48
|
+
def test_http_client_subscribe_starts_local_server(self) -> None:
|
|
49
|
+
received: dict | None = None
|
|
50
|
+
event = threading.Event()
|
|
51
|
+
|
|
52
|
+
cfg = HttpConfig(listen_host="127.0.0.1", listen_port=0)
|
|
53
|
+
client = HttpClient(cfg)
|
|
54
|
+
|
|
55
|
+
def _handler(payload: dict) -> None:
|
|
56
|
+
nonlocal received
|
|
57
|
+
received = payload
|
|
58
|
+
event.set()
|
|
59
|
+
|
|
60
|
+
client.subscribe("/edge/events", _handler)
|
|
61
|
+
port = client.listening_port
|
|
62
|
+
|
|
63
|
+
req = urllib.request.Request(
|
|
64
|
+
f"http://127.0.0.1:{port}/edge/events",
|
|
65
|
+
data=json.dumps({"camera_id": "cam01"}).encode("utf-8"),
|
|
66
|
+
headers={"Content-Type": "application/json"},
|
|
67
|
+
method="POST",
|
|
68
|
+
)
|
|
69
|
+
with urllib.request.urlopen(req, timeout=2):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
self.assertTrue(event.wait(timeout=2))
|
|
73
|
+
self.assertEqual(received, {"camera_id": "cam01"})
|
|
74
|
+
client.close()
|