fastapi-modular 0.1.0__py3-none-any.whl

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 (69) hide show
  1. fastapi_modular-0.1.0.dist-info/METADATA +377 -0
  2. fastapi_modular-0.1.0.dist-info/RECORD +69 -0
  3. fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
  4. fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
  5. fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. pymodular/__init__.py +74 -0
  7. pymodular/cli/__init__.py +0 -0
  8. pymodular/cli/clean.py +39 -0
  9. pymodular/cli/configure_env.py +569 -0
  10. pymodular/cli/cong_cu.py +111 -0
  11. pymodular/cli/info.py +62 -0
  12. pymodular/cli/install.py +83 -0
  13. pymodular/cli/main.py +247 -0
  14. pymodular/cli/new_module.py +492 -0
  15. pymodular/cli/new_project.py +471 -0
  16. pymodular/cli/serve.py +59 -0
  17. pymodular/core/__init__.py +0 -0
  18. pymodular/core/clock.py +15 -0
  19. pymodular/core/compat.py +39 -0
  20. pymodular/core/config.py +495 -0
  21. pymodular/core/container.py +354 -0
  22. pymodular/core/context.py +78 -0
  23. pymodular/core/controller.py +208 -0
  24. pymodular/core/error_handlers.py +272 -0
  25. pymodular/core/exceptions.py +104 -0
  26. pymodular/core/guards.py +117 -0
  27. pymodular/core/lifespan.py +150 -0
  28. pymodular/core/logging.py +88 -0
  29. pymodular/core/metrics.py +190 -0
  30. pymodular/core/schemas.py +105 -0
  31. pymodular/core/websocket/__init__.py +31 -0
  32. pymodular/core/websocket/adapter.py +192 -0
  33. pymodular/core/websocket/gateway.py +735 -0
  34. pymodular/core/websocket/namespace.py +148 -0
  35. pymodular/core/websocket/protocol.py +157 -0
  36. pymodular/core/websocket/server.py +175 -0
  37. pymodular/core/websocket/socket.py +241 -0
  38. pymodular/discovery.py +180 -0
  39. pymodular/factory.py +126 -0
  40. pymodular/infrastructure/__init__.py +1 -0
  41. pymodular/infrastructure/database/__init__.py +8 -0
  42. pymodular/infrastructure/database/base.py +228 -0
  43. pymodular/infrastructure/database/circuit.py +207 -0
  44. pymodular/infrastructure/database/factory.py +88 -0
  45. pymodular/infrastructure/database/memory.py +112 -0
  46. pymodular/infrastructure/database/mongo.py +186 -0
  47. pymodular/infrastructure/database/repository.py +188 -0
  48. pymodular/infrastructure/database/sql.py +520 -0
  49. pymodular/infrastructure/kafka/__init__.py +26 -0
  50. pymodular/infrastructure/kafka/broker.py +231 -0
  51. pymodular/infrastructure/kafka/consumers.py +371 -0
  52. pymodular/infrastructure/kafka/metrics.py +17 -0
  53. pymodular/infrastructure/mqtt/__init__.py +35 -0
  54. pymodular/infrastructure/mqtt/client.py +292 -0
  55. pymodular/infrastructure/mqtt/consumers.py +219 -0
  56. pymodular/infrastructure/mqtt/metrics.py +17 -0
  57. pymodular/infrastructure/mqtt/patterns.py +116 -0
  58. pymodular/infrastructure/rabbitmq/__init__.py +33 -0
  59. pymodular/infrastructure/rabbitmq/broker.py +616 -0
  60. pymodular/infrastructure/rabbitmq/consumers.py +450 -0
  61. pymodular/infrastructure/rabbitmq/metrics.py +34 -0
  62. pymodular/infrastructure/rabbitmq/patterns.py +64 -0
  63. pymodular/infrastructure/redis/__init__.py +31 -0
  64. pymodular/infrastructure/redis/client.py +362 -0
  65. pymodular/infrastructure/redis/metrics.py +20 -0
  66. pymodular/infrastructure/redis/pubsub.py +262 -0
  67. pymodular/middleware/__init__.py +0 -0
  68. pymodular/middleware/request_context.py +164 -0
  69. pymodular/py.typed +0 -0
@@ -0,0 +1,148 @@
1
+ """Namespace — sổ đăng ký các kết nối của MỘT gateway.
2
+
3
+ Mỗi gateway (mỗi đường dẫn WebSocket) có namespace riêng, nên phòng "alerts"
4
+ của `/ws/chat` và phòng "alerts" của `/ws/telemetry` là hai phòng khác nhau.
5
+ Giống namespace của socket.io mà NestJS dùng.
6
+
7
+ Ba chỉ mục được giữ song song, tất cả đều là O(1):
8
+
9
+ socket_id -> Socket gửi thẳng cho một kết nối
10
+ room -> {socket_id} gửi cho một phòng
11
+ user_id -> {socket_id} gửi cho một người, kể cả khi họ mở nhiều tab
12
+
13
+ Chỉ mục thứ ba là thứ hay bị bỏ sót. Không có nó thì "gửi thông báo cho user
14
+ X" phải quét toàn bộ kết nối; với vài nghìn kết nối, mỗi lần gửi là một vòng
15
+ lặp toàn bộ.
16
+
17
+ Mọi thao tác ở đây là ĐỒNG BỘ và chỉ chạy trong event loop của tiến trình này
18
+ nên không cần khoá. Nhiều worker thì mỗi worker có sổ riêng — xem adapter.py.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Iterable, Iterator
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from pymodular.core.logging import get_logger
27
+
28
+ if TYPE_CHECKING:
29
+ from pymodular.core.websocket.socket import Socket
30
+
31
+ log = get_logger(__name__)
32
+
33
+
34
+ class Namespace:
35
+ __slots__ = ("_rooms", "_sockets", "_users", "path")
36
+
37
+ def __init__(self, path: str) -> None:
38
+ self.path = path
39
+ self._sockets: dict[str, Socket] = {}
40
+ self._rooms: dict[str, set[str]] = {}
41
+ self._users: dict[str, set[str]] = {}
42
+
43
+ # --------------------------------------------------------- vào/ra sổ
44
+ def add(self, socket: Socket) -> None:
45
+ self._sockets[socket.id] = socket
46
+ if socket.user_id:
47
+ self._users.setdefault(socket.user_id, set()).add(socket.id)
48
+
49
+ def remove(self, socket: Socket) -> None:
50
+ self._sockets.pop(socket.id, None)
51
+
52
+ for room in list(socket.rooms):
53
+ self.leave(socket, room)
54
+
55
+ if socket.user_id:
56
+ ids = self._users.get(socket.user_id)
57
+ if ids is not None:
58
+ ids.discard(socket.id)
59
+ # Xoá khoá rỗng, nếu không dict phình theo số user từng vào.
60
+ if not ids:
61
+ del self._users[socket.user_id]
62
+
63
+ # ------------------------------------------------------------- phòng
64
+ def join(self, socket: Socket, room: str) -> None:
65
+ self._rooms.setdefault(room, set()).add(socket.id)
66
+ socket.rooms.add(room)
67
+
68
+ def leave(self, socket: Socket, room: str) -> None:
69
+ members = self._rooms.get(room)
70
+ if members is not None:
71
+ members.discard(socket.id)
72
+ if not members:
73
+ del self._rooms[room]
74
+ socket.rooms.discard(room)
75
+
76
+ def room_size(self, room: str) -> int:
77
+ return len(self._rooms.get(room, ()))
78
+
79
+ def sockets_in(self, room: str) -> list[Socket]:
80
+ return [s for sid in self._rooms.get(room, ()) if (s := self._sockets.get(sid))]
81
+
82
+ def sockets_of(self, user_id: str) -> list[Socket]:
83
+ return [s for sid in self._users.get(user_id, ()) if (s := self._sockets.get(sid))]
84
+
85
+ def get(self, socket_id: str) -> Socket | None:
86
+ return self._sockets.get(socket_id)
87
+
88
+ # -------------------------------------------------------------- gửi
89
+ def deliver(
90
+ self,
91
+ event: str,
92
+ data: Any = None,
93
+ *,
94
+ room: str | None = None,
95
+ user: str | None = None,
96
+ socket: str | None = None,
97
+ exclude: Iterable[str] = (),
98
+ ) -> int:
99
+ """Gửi cho các kết nối ĐANG Ở TIẾN TRÌNH NÀY. Trả về số tin đã xếp hàng.
100
+
101
+ Không có tiêu chí nào (`room`/`user`/`socket` đều None) nghĩa là gửi
102
+ cho toàn bộ namespace.
103
+ """
104
+ skip = set(exclude)
105
+ count = 0
106
+ for target in self._targets(room=room, user=user, socket=socket):
107
+ if target.id in skip or target.closing:
108
+ continue
109
+ if target.emit(event, data):
110
+ count += 1
111
+ return count
112
+
113
+ def _targets(
114
+ self, *, room: str | None, user: str | None, socket: str | None
115
+ ) -> Iterator[Socket]:
116
+ if socket is not None:
117
+ found = self._sockets.get(socket)
118
+ if found is not None:
119
+ yield found
120
+ return
121
+ if user is not None:
122
+ yield from self.sockets_of(user)
123
+ return
124
+ if room is not None:
125
+ yield from self.sockets_in(room)
126
+ return
127
+ yield from list(self._sockets.values())
128
+
129
+ # ------------------------------------------------------------ thống kê
130
+ def __len__(self) -> int:
131
+ return len(self._sockets)
132
+
133
+ @property
134
+ def sockets(self) -> list[Socket]:
135
+ return list(self._sockets.values())
136
+
137
+ @property
138
+ def rooms(self) -> dict[str, int]:
139
+ return {room: len(ids) for room, ids in self._rooms.items()}
140
+
141
+ def stats(self) -> dict[str, Any]:
142
+ return {
143
+ "path": self.path,
144
+ "sockets": len(self._sockets),
145
+ "users": len(self._users),
146
+ "rooms": len(self._rooms),
147
+ "pending": sum(s.pending for s in self._sockets.values()),
148
+ }
@@ -0,0 +1,157 @@
1
+ """Khuôn tin nhắn và mã đóng kết nối.
2
+
3
+ Một kết nối WebSocket là một ống byte trần: không có URL, không có status code,
4
+ không có content-type. Muốn nhiều loại nghiệp vụ đi chung một kết nối thì phải
5
+ tự quy ước, và quy ước đó chính là "giao thức" dưới đây — tương đương cặp
6
+ `event` + `data` của socket.io mà NestJS dùng.
7
+
8
+ Mọi khung tin là JSON một tầng:
9
+
10
+ {"event": "room.join", "data": {"room": "alerts"}, "id": "c1"}
11
+
12
+ - `event` : tên nghiệp vụ, quyết định handler nào chạy. Bắt buộc.
13
+ - `data` : tham số, tuỳ handler. Thiếu thì hiểu là `null`.
14
+ - `id` : mã do CLIENT tự đặt để ghép câu trả lời với câu hỏi. Tuỳ chọn.
15
+
16
+ Server trả lời bằng cùng khuôn, thêm `ack` = `id` của khung hỏi:
17
+
18
+ {"event": "room.join", "data": {"room": "alerts", "size": 3}, "ack": "c1"}
19
+ {"event": "error", "data": {"code": "forbidden", "message": "..."}, "ack": "c1"}
20
+
21
+ Server đẩy chủ động thì không có `ack`:
22
+
23
+ {"event": "alert.created", "data": {...}}
24
+
25
+ Khung có thêm ô `meta` tuỳ chọn, nội dung do bên gửi tự quyết. Lõi không đọc
26
+ tới nó; nó dành cho các lớp bên ngoài muốn kèm nguồn gốc của tin, ví dụ:
27
+
28
+ {"event": "alert.created", "data": {...}, "meta": {"nguon": "..."}}
29
+
30
+ Vì sao cần `id`/`ack`: WebSocket không ghép cặp request-response như HTTP. Gửi
31
+ ba lệnh liên tiếp rồi nhận ba câu trả lời thì không có cách nào biết cái nào
32
+ của cái nào, trừ khi tự đánh số.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ from dataclasses import dataclass
39
+ from enum import IntEnum
40
+ from typing import Any
41
+
42
+ from pymodular.core.exceptions import AppError
43
+
44
+
45
+ class CloseCode(IntEnum):
46
+ """Mã đóng kết nối.
47
+
48
+ 1000–2999 do chuẩn WebSocket định nghĩa; 4000–4999 dành cho ứng dụng tự
49
+ đặt. Ở đây dải 44xx cố ý ánh xạ 1-1 với HTTP status quen thuộc (4401 ~ 401,
50
+ 4403 ~ 403...) để đọc log không phải tra bảng.
51
+ """
52
+
53
+ NORMAL = 1000
54
+ GOING_AWAY = 1001 # server tắt/khởi động lại
55
+ PROTOCOL_ERROR = 1002
56
+ TOO_BIG = 1009 # khung tin vượt ngưỡng
57
+ TRY_AGAIN_LATER = 1013 # client đọc quá chậm, hàng đợi gửi đã đầy
58
+
59
+ BAD_REQUEST = 4400
60
+ UNAUTHORIZED = 4401
61
+ FORBIDDEN = 4403
62
+ NOT_FOUND = 4404
63
+ IDLE_TIMEOUT = 4408 # im lặng quá lâu, coi như đã chết
64
+ CONFLICT = 4409
65
+ TOO_MANY = 4429 # vượt hạn mức kết nối hoặc tần suất gửi
66
+ INTERNAL = 4500
67
+
68
+
69
+ # AppError mang status_code HTTP; đổi sang mã đóng tương ứng để client dùng
70
+ # chung một bảng lỗi cho cả REST lẫn WebSocket.
71
+ _HTTP_TO_CLOSE: dict[int, CloseCode] = {
72
+ 400: CloseCode.BAD_REQUEST,
73
+ 401: CloseCode.UNAUTHORIZED,
74
+ 403: CloseCode.FORBIDDEN,
75
+ 404: CloseCode.NOT_FOUND,
76
+ 409: CloseCode.CONFLICT,
77
+ 422: CloseCode.BAD_REQUEST,
78
+ 429: CloseCode.TOO_MANY,
79
+ }
80
+
81
+
82
+ def close_code_for(exc: BaseException) -> CloseCode:
83
+ if isinstance(exc, AppError):
84
+ return _HTTP_TO_CLOSE.get(exc.status_code, CloseCode.INTERNAL)
85
+ return CloseCode.INTERNAL
86
+
87
+
88
+ # Tên sự kiện do framework giữ chỗ; gateway không được đăng ký trùng.
89
+ EVENT_CONNECTED = "connected"
90
+ EVENT_ERROR = "error"
91
+ EVENT_PING = "ping"
92
+ EVENT_PONG = "pong"
93
+ EVENT_ROOM_JOIN = "room.join"
94
+ EVENT_ROOM_LEAVE = "room.leave"
95
+
96
+ RESERVED_EVENTS = frozenset(
97
+ {EVENT_CONNECTED, EVENT_ERROR, EVENT_PONG, EVENT_ROOM_JOIN, EVENT_ROOM_LEAVE}
98
+ )
99
+
100
+
101
+ class ProtocolError(AppError):
102
+ """Khung tin sai khuôn — không phải JSON, thiếu `event`, hoặc quá dài."""
103
+
104
+ status_code = 400
105
+ error_code = "ws_protocol_error"
106
+ message = "Khung tin không hợp lệ"
107
+
108
+
109
+ @dataclass(slots=True)
110
+ class Frame:
111
+ event: str
112
+ data: Any = None
113
+ id: str | None = None
114
+ ack: str | None = None
115
+ meta: dict[str, Any] | None = None
116
+
117
+ def to_json(self) -> str:
118
+ payload: dict[str, Any] = {"event": self.event, "data": self.data}
119
+ if self.ack is not None:
120
+ payload["ack"] = self.ack
121
+ if self.meta is not None:
122
+ payload["meta"] = self.meta
123
+ # default=str để datetime/UUID/Decimal trong data không làm chết vòng
124
+ # gửi. Mất kiểu còn hơn mất kết nối.
125
+ return json.dumps(payload, ensure_ascii=False, default=str)
126
+
127
+
128
+ def parse_frame(raw: str) -> Frame:
129
+ """Đọc khung tin từ client. Ném ProtocolError nếu sai khuôn."""
130
+ try:
131
+ payload = json.loads(raw)
132
+ except (json.JSONDecodeError, ValueError) as exc:
133
+ raise ProtocolError(f"Không phải JSON hợp lệ: {exc}") from exc
134
+
135
+ if not isinstance(payload, dict):
136
+ raise ProtocolError("Khung tin phải là một object JSON")
137
+
138
+ event = payload.get("event")
139
+ if not isinstance(event, str) or not event:
140
+ raise ProtocolError("Thiếu trường 'event'")
141
+
142
+ frame_id = payload.get("id")
143
+ if frame_id is not None and not isinstance(frame_id, str):
144
+ raise ProtocolError("Trường 'id' phải là chuỗi")
145
+
146
+ return Frame(event=event, data=payload.get("data"), id=frame_id)
147
+
148
+
149
+ def error_frame(exc: BaseException, *, ack: str | None = None, debug: bool = False) -> Frame:
150
+ """Đổi exception thành khung `error` cùng hình dạng với lỗi HTTP."""
151
+ if isinstance(exc, AppError):
152
+ return Frame(event=EVENT_ERROR, data=exc.to_dict(), ack=ack)
153
+
154
+ data: dict[str, Any] = {"code": "internal_error", "message": "Internal server error"}
155
+ if debug:
156
+ data["details"] = f"{type(exc).__name__}: {exc}"
157
+ return Frame(event=EVENT_ERROR, data=data, ack=ack)
@@ -0,0 +1,175 @@
1
+ """`WebSocketServer` — điểm vào duy nhất để đẩy tin, dùng được từ mọi nơi.
2
+
3
+ Tương đương `@WebSocketServer() server: Server` của NestJS, nhưng lấy qua DI
4
+ như mọi provider khác:
5
+
6
+ @injectable
7
+ class AlertService:
8
+ def __init__(self, ws: WebSocketServer) -> None:
9
+ self._ws = ws
10
+
11
+ async def raise_alert(self, alert):
12
+ await self._ws.to_room("alerts", "alert.created", {"id": alert.id})
13
+
14
+ Gửi được từ controller HTTP, từ service, từ tác vụ nền — không cần đang ở
15
+ trong một kết nối WebSocket nào. Server tự lo hai việc: gửi cho kết nối trong
16
+ tiến trình này, và đăng tin lên adapter cho các worker khác.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Iterable
22
+ from typing import Any
23
+
24
+ from pymodular.core.config import Settings
25
+ from pymodular.core.container import injectable
26
+ from pymodular.core.logging import get_logger
27
+ from pymodular.core.metrics import ws_connections
28
+ from pymodular.core.websocket.adapter import (
29
+ BroadcastAdapter,
30
+ build_adapter,
31
+ envelope,
32
+ new_origin_id,
33
+ )
34
+ from pymodular.core.websocket.namespace import Namespace
35
+ from pymodular.core.websocket.protocol import CloseCode
36
+
37
+ log = get_logger(__name__)
38
+
39
+
40
+ @injectable
41
+ class WebSocketServer:
42
+ def __init__(self, settings: Settings) -> None:
43
+ self._config = settings.ws
44
+ self._namespaces: dict[str, Namespace] = {}
45
+ self._origin = new_origin_id()
46
+ self._adapter: BroadcastAdapter = build_adapter(
47
+ self._config.adapter,
48
+ url=self._config.redis_url,
49
+ channel=self._config.channel,
50
+ origin=self._origin,
51
+ )
52
+ self._started = False
53
+
54
+ # ------------------------------------------------------------- namespace
55
+ def namespace(self, path: str) -> Namespace:
56
+ """Lấy (hoặc tạo) namespace của một đường dẫn WebSocket."""
57
+ found = self._namespaces.get(path)
58
+ if found is None:
59
+ found = self._namespaces[path] = Namespace(path)
60
+ return found
61
+
62
+ @property
63
+ def namespaces(self) -> dict[str, Namespace]:
64
+ return dict(self._namespaces)
65
+
66
+ def _resolve(self, path: str | None) -> Namespace:
67
+ if path is not None:
68
+ return self.namespace(path)
69
+ if len(self._namespaces) == 1:
70
+ return next(iter(self._namespaces.values()))
71
+ raise RuntimeError(
72
+ "Có nhiều gateway nên phải nói rõ gửi vào namespace nào: "
73
+ f"emit(..., namespace='/ws/chat'). Đang có: {sorted(self._namespaces)}"
74
+ )
75
+
76
+ # ------------------------------------------------------------------ gửi
77
+ async def emit(
78
+ self,
79
+ event: str,
80
+ data: Any = None,
81
+ *,
82
+ namespace: str | None = None,
83
+ room: str | None = None,
84
+ user: str | None = None,
85
+ socket: str | None = None,
86
+ exclude: Iterable[str] = (),
87
+ local_only: bool = False,
88
+ ) -> int:
89
+ """Đẩy một tin. Trả về số kết nối TẠI TIẾN TRÌNH NÀY đã nhận.
90
+
91
+ Số trả về cố ý không tính các worker khác: adapter là fire-and-forget,
92
+ biết chắc bên kia gửi được bao nhiêu thì phải chờ phản hồi, mà chờ thì
93
+ mất hết ý nghĩa "gửi không chặn".
94
+ """
95
+ target = self._resolve(namespace)
96
+ delivered = target.deliver(
97
+ event, data, room=room, user=user, socket=socket, exclude=exclude
98
+ )
99
+
100
+ if not local_only and self._adapter.name != "local":
101
+ await self._adapter.publish(
102
+ envelope(
103
+ self._origin,
104
+ target.path,
105
+ event,
106
+ data,
107
+ room=room,
108
+ user=user,
109
+ socket=socket,
110
+ exclude=exclude,
111
+ )
112
+ )
113
+ return delivered
114
+
115
+ async def to_room(self, room: str, event: str, data: Any = None, **kwargs: Any) -> int:
116
+ return await self.emit(event, data, room=room, **kwargs)
117
+
118
+ async def to_user(self, user_id: str, event: str, data: Any = None, **kwargs: Any) -> int:
119
+ """Gửi cho MỘT người — tới mọi kết nối của họ (nhiều tab, nhiều thiết bị)."""
120
+ return await self.emit(event, data, user=user_id, **kwargs)
121
+
122
+ async def to_socket(self, socket_id: str, event: str, data: Any = None, **kwargs: Any) -> int:
123
+ return await self.emit(event, data, socket=socket_id, **kwargs)
124
+
125
+ async def broadcast(self, event: str, data: Any = None, **kwargs: Any) -> int:
126
+ """Gửi cho mọi kết nối trong namespace."""
127
+ return await self.emit(event, data, **kwargs)
128
+
129
+ # ------------------------------------------------------- tin từ worker khác
130
+ def _on_remote(self, payload: dict[str, Any]) -> None:
131
+ path = payload.get("ns")
132
+ if not path or path not in self._namespaces:
133
+ return # worker này không phục vụ namespace đó
134
+ self._namespaces[path].deliver(
135
+ payload.get("event", ""),
136
+ payload.get("data"),
137
+ room=payload.get("room"),
138
+ user=payload.get("user"),
139
+ socket=payload.get("socket"),
140
+ exclude=payload.get("exclude") or (),
141
+ )
142
+
143
+ # ------------------------------------------------------------ vòng đời
144
+ async def startup(self) -> None:
145
+ if self._started:
146
+ return
147
+ await self._adapter.start(self._on_remote)
148
+ self._started = True
149
+
150
+ async def shutdown(self) -> None:
151
+ """Đóng mọi kết nối tử tế trước khi tiến trình thoát.
152
+
153
+ Mã 1001 (going away) báo cho client biết đây là restart chứ không phải
154
+ lỗi, để phía client nối lại ngay thay vì chờ hết backoff.
155
+ """
156
+ for namespace in self._namespaces.values():
157
+ for socket in namespace.sockets:
158
+ await socket.close(CloseCode.GOING_AWAY, "server đang tắt")
159
+ ws_connections.set(0, namespace=namespace.path)
160
+ await self._adapter.stop()
161
+ self._namespaces.clear()
162
+ self._started = False
163
+
164
+ # ------------------------------------------------------------ thống kê
165
+ @property
166
+ def adapter_name(self) -> str:
167
+ return self._adapter.name
168
+
169
+ def stats(self) -> dict[str, Any]:
170
+ return {
171
+ "adapter": self._adapter.name,
172
+ "origin": self._origin,
173
+ "namespaces": [ns.stats() for ns in self._namespaces.values()],
174
+ "connections": sum(len(ns) for ns in self._namespaces.values()),
175
+ }