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.
Files changed (22) hide show
  1. smart_messaging_core-0.1.1/LICENSE +21 -0
  2. smart_messaging_core-0.1.1/PKG-INFO +63 -0
  3. smart_messaging_core-0.1.1/README.md +35 -0
  4. smart_messaging_core-0.1.1/pyproject.toml +46 -0
  5. smart_messaging_core-0.1.1/setup.cfg +4 -0
  6. smart_messaging_core-0.1.1/src/smart_messaging_core/__init__.py +37 -0
  7. smart_messaging_core-0.1.1/src/smart_messaging_core/base.py +42 -0
  8. smart_messaging_core-0.1.1/src/smart_messaging_core/client.py +101 -0
  9. smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/__init__.py +19 -0
  10. smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/http.py +135 -0
  11. smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/kafka.py +60 -0
  12. smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/mqtt.py +154 -0
  13. smart_messaging_core-0.1.1/src/smart_messaging_core/protocols/redis.py +56 -0
  14. smart_messaging_core-0.1.1/src/smart_messaging_core/registry.py +17 -0
  15. smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/PKG-INFO +63 -0
  16. smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/SOURCES.txt +20 -0
  17. smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/dependency_links.txt +1 -0
  18. smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/requires.txt +7 -0
  19. smart_messaging_core-0.1.1/src/smart_messaging_core.egg-info/top_level.txt +1 -0
  20. smart_messaging_core-0.1.1/tests/test_client_routes.py +72 -0
  21. smart_messaging_core-0.1.1/tests/test_imports.py +20 -0
  22. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ paho-mqtt>=1.6.1
2
+ kafka-python>=2.0.2
3
+ redis>=5.0.0
4
+
5
+ [dev]
6
+ build>=1.2.2
7
+ twine>=5.1.1
@@ -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()