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,88 @@
1
+ """Cấu hình structlog + stdlib logging thành một đường ống duy nhất."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Any
8
+
9
+ import structlog
10
+
11
+ from pymodular.core.config import LogSettings
12
+ from pymodular.core.context import get_request_id, get_trace_id, get_user_id
13
+
14
+
15
+ def _inject_context(
16
+ _logger: Any, _method: str, event_dict: structlog.types.EventDict
17
+ ) -> structlog.types.EventDict:
18
+ """Gắn request_id / trace_id / user_id từ contextvars vào mọi dòng log."""
19
+ request_id = get_request_id()
20
+ if request_id:
21
+ event_dict.setdefault("request_id", request_id)
22
+ trace_id = get_trace_id()
23
+ if trace_id:
24
+ # trace_id nối log của dịch vụ này với log của các dịch vụ khác trong
25
+ # cùng một hành trình; request_id chỉ có ý nghĩa trong dịch vụ này.
26
+ event_dict.setdefault("trace_id", trace_id)
27
+ user_id = get_user_id()
28
+ if user_id:
29
+ event_dict.setdefault("user_id", user_id)
30
+ return event_dict
31
+
32
+
33
+ def configure_logging(settings: LogSettings) -> None:
34
+ level = getattr(logging, settings.level.upper(), logging.INFO)
35
+
36
+ shared_processors: list[structlog.types.Processor] = [
37
+ structlog.contextvars.merge_contextvars,
38
+ _inject_context,
39
+ structlog.processors.add_log_level,
40
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
41
+ structlog.processors.StackInfoRenderer(),
42
+ ]
43
+
44
+ renderer: structlog.types.Processor = (
45
+ structlog.processors.JSONRenderer()
46
+ if settings.json_format
47
+ else structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty())
48
+ )
49
+
50
+ structlog.configure(
51
+ processors=[
52
+ *shared_processors,
53
+ structlog.processors.format_exc_info,
54
+ renderer,
55
+ ],
56
+ wrapper_class=structlog.make_filtering_bound_logger(level),
57
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
58
+ cache_logger_on_first_use=True,
59
+ )
60
+
61
+ # Đưa log của thư viện bên thứ ba (uvicorn, sqlalchemy, ...) qua structlog.
62
+ handler = logging.StreamHandler(sys.stdout)
63
+ handler.setFormatter(
64
+ structlog.stdlib.ProcessorFormatter(
65
+ foreign_pre_chain=shared_processors,
66
+ processors=[
67
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
68
+ renderer,
69
+ ],
70
+ )
71
+ )
72
+
73
+ root = logging.getLogger()
74
+ root.handlers = [handler]
75
+ root.setLevel(level)
76
+
77
+ for noisy in ("uvicorn", "uvicorn.error", "uvicorn.access"):
78
+ lg = logging.getLogger(noisy)
79
+ lg.handlers = []
80
+ lg.propagate = True
81
+
82
+ # uvicorn.access bị tắt vì middleware AccessLog đã ghi log request đầy đủ hơn.
83
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
84
+ logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
85
+
86
+
87
+ def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
88
+ return structlog.get_logger(name)
@@ -0,0 +1,190 @@
1
+ """Số đo dạng Prometheus, viết tay để không phải thêm thư viện.
2
+
3
+ Chỉ có ba loại cần dùng: Counter (chỉ tăng), Gauge (lên xuống), Histogram
4
+ (phân bố độ trễ). Đủ để trả lời bốn câu hỏi vận hành quan trọng nhất: bao nhiêu
5
+ request, bao nhiêu lỗi, chậm cỡ nào, và tài nguyên còn bao nhiêu.
6
+
7
+ Một cạm bẫy phải tránh: KHÔNG lấy đường dẫn thật làm nhãn. `/api/users/abc123`
8
+ và `/api/users/def456` là hai nhãn khác nhau, mỗi user tạo một chuỗi số đo mới
9
+ và làm nổ bộ nhớ Prometheus. Phải dùng khuôn đường dẫn `/api/users/{user_id}`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ import time
16
+ from collections import defaultdict
17
+ from typing import Any
18
+
19
+ Labels = tuple[tuple[str, str], ...]
20
+
21
+ # Mốc chia histogram, tính bằng giây. Chọn quanh ngưỡng người dùng cảm nhận
22
+ # được: dưới 100ms là nhanh, trên 1s là chậm, trên 5s coi như hỏng.
23
+ DEFAULT_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
24
+
25
+
26
+ def _labels(**kwargs: Any) -> Labels:
27
+ return tuple(sorted((k, str(v)) for k, v in kwargs.items()))
28
+
29
+
30
+ def _render_labels(labels: Labels) -> str:
31
+ if not labels:
32
+ return ""
33
+ inner = ",".join(f'{k}="{_escape(v)}"' for k, v in labels)
34
+ return "{" + inner + "}"
35
+
36
+
37
+ def _escape(value: str) -> str:
38
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
39
+
40
+
41
+ class Counter:
42
+ def __init__(self, name: str, help_text: str) -> None:
43
+ self.name = name
44
+ self.help = help_text
45
+ self._values: dict[Labels, float] = defaultdict(float)
46
+ self._lock = threading.Lock()
47
+
48
+ def inc(self, amount: float = 1.0, **labels: Any) -> None:
49
+ with self._lock:
50
+ self._values[_labels(**labels)] += amount
51
+
52
+ def render(self) -> list[str]:
53
+ lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} counter"]
54
+ with self._lock:
55
+ for labels, value in sorted(self._values.items()):
56
+ lines.append(f"{self.name}{_render_labels(labels)} {value:g}")
57
+ return lines
58
+
59
+
60
+ class Gauge:
61
+ def __init__(self, name: str, help_text: str) -> None:
62
+ self.name = name
63
+ self.help = help_text
64
+ self._values: dict[Labels, float] = defaultdict(float)
65
+ self._lock = threading.Lock()
66
+
67
+ def set(self, value: float, **labels: Any) -> None:
68
+ with self._lock:
69
+ self._values[_labels(**labels)] = value
70
+
71
+ def inc_gauge(self, amount: float = 1.0, **labels: Any) -> None:
72
+ with self._lock:
73
+ self._values[_labels(**labels)] += amount
74
+
75
+ def render(self) -> list[str]:
76
+ lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} gauge"]
77
+ with self._lock:
78
+ for labels, value in sorted(self._values.items()):
79
+ lines.append(f"{self.name}{_render_labels(labels)} {value:g}")
80
+ return lines
81
+
82
+
83
+ class Histogram:
84
+ def __init__(
85
+ self, name: str, help_text: str, buckets: tuple[float, ...] = DEFAULT_BUCKETS
86
+ ) -> None:
87
+ self.name = name
88
+ self.help = help_text
89
+ self.buckets = buckets
90
+ self._counts: dict[Labels, list[int]] = defaultdict(lambda: [0] * len(buckets))
91
+ self._sums: dict[Labels, float] = defaultdict(float)
92
+ self._totals: dict[Labels, int] = defaultdict(int)
93
+ self._lock = threading.Lock()
94
+
95
+ def observe(self, value: float, **labels: Any) -> None:
96
+ key = _labels(**labels)
97
+ with self._lock:
98
+ counts = self._counts[key]
99
+ for index, edge in enumerate(self.buckets):
100
+ if value <= edge:
101
+ counts[index] += 1
102
+ self._sums[key] += value
103
+ self._totals[key] += 1
104
+
105
+ def render(self) -> list[str]:
106
+ lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} histogram"]
107
+ with self._lock:
108
+ for key in sorted(self._totals):
109
+ counts = self._counts[key]
110
+ for edge, count in zip(self.buckets, counts, strict=True):
111
+ labels = _render_labels((*key, ("le", str(edge))))
112
+ lines.append(f"{self.name}_bucket{labels} {count}")
113
+ total = self._totals[key]
114
+ lines.append(f"{self.name}_bucket{_render_labels((*key, ('le', '+Inf')))} {total}")
115
+ lines.append(f"{self.name}_sum{_render_labels(key)} {self._sums[key]:g}")
116
+ lines.append(f"{self.name}_count{_render_labels(key)} {total}")
117
+ return lines
118
+
119
+
120
+ class Registry:
121
+ def __init__(self) -> None:
122
+ self._metrics: list[Counter | Gauge | Histogram] = []
123
+ self._callbacks: list[Any] = []
124
+
125
+ def register(self, metric: Counter | Gauge | Histogram):
126
+ self._metrics.append(metric)
127
+ return metric
128
+
129
+ def on_scrape(self, callback: Any) -> None:
130
+ """Hàm được gọi ngay trước khi xuất số đo, để cập nhật gauge tức thời."""
131
+ self._callbacks.append(callback)
132
+
133
+ def render(self) -> str:
134
+ for callback in self._callbacks:
135
+ callback()
136
+ lines: list[str] = []
137
+ for metric in self._metrics:
138
+ lines.extend(metric.render())
139
+ return "\n".join(lines) + "\n"
140
+
141
+
142
+ registry = Registry()
143
+
144
+ http_requests = registry.register(
145
+ Counter("http_requests_total", "Số HTTP request đã xử lý")
146
+ )
147
+ http_duration = registry.register(
148
+ Histogram("http_request_duration_seconds", "Thời gian xử lý HTTP request")
149
+ )
150
+ http_in_flight = registry.register(
151
+ Gauge("http_requests_in_flight", "Số request đang xử lý")
152
+ )
153
+ app_info = registry.register(Gauge("app_info", "Thông tin phiên bản ứng dụng"))
154
+ db_circuit_state = registry.register(
155
+ Gauge("db_circuit_state", "Trạng thái ngắt mạch database (0=đóng 1=nửa mở 2=ngắt)")
156
+ )
157
+
158
+ # ---- WebSocket ----------------------------------------------------------
159
+ # Nhãn chỉ có `namespace` và `event`, cả hai đều là tập hữu hạn do code khai
160
+ # báo. Tuyệt đối không lấy socket_id hay user_id làm nhãn: mỗi người dùng sẽ
161
+ # đẻ ra một chuỗi số đo mới và làm nổ Prometheus.
162
+ ws_connections = registry.register(
163
+ Gauge("ws_connections", "Số kết nối WebSocket đang mở")
164
+ )
165
+ ws_connections_total = registry.register(
166
+ Counter("ws_connections_total", "Tổng số kết nối WebSocket đã mở")
167
+ )
168
+ ws_messages_in = registry.register(
169
+ Counter("ws_messages_in_total", "Số khung tin nhận từ client")
170
+ )
171
+ ws_messages_out = registry.register(
172
+ Counter("ws_messages_out_total", "Số khung tin đã xếp hàng gửi cho client")
173
+ )
174
+ ws_send_dropped = registry.register(
175
+ Counter("ws_send_dropped_total", "Số khung tin bị bỏ vì hàng đợi gửi đầy")
176
+ )
177
+
178
+
179
+ class Timer:
180
+ """Đo thời gian một khối lệnh bằng đồng hồ đơn điệu."""
181
+
182
+ __slots__ = ("_started", "elapsed")
183
+
184
+ def __enter__(self) -> Timer:
185
+ self._started = time.perf_counter()
186
+ self.elapsed = 0.0
187
+ return self
188
+
189
+ def __exit__(self, *exc: object) -> None:
190
+ self.elapsed = time.perf_counter() - self._started
@@ -0,0 +1,105 @@
1
+ """Lớp nền cho DTO và vài tiện ích dùng chung cho mọi module.
2
+
3
+ Ba thứ ở đây thay cho việc lặp `model_config` trong từng file DTO:
4
+
5
+ - `InputSchema` — nền cho DTO đi vào (body request)
6
+ - `OutputSchema` — nền cho DTO đi ra (response)
7
+ - `partial_of()` — sinh biến thể "mọi field optional" cho PATCH, giữ nguyên
8
+ ràng buộc validate, thay cho việc chép tay lần thứ hai
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ from typing import Any, Generic, TypeVar
15
+
16
+ from pydantic import BaseModel, ConfigDict, create_model
17
+
18
+ T = TypeVar("T")
19
+ ModelT = TypeVar("ModelT", bound=BaseModel)
20
+
21
+
22
+ class InputSchema(BaseModel):
23
+ """Nền cho mọi DTO nhận từ client.
24
+
25
+ - `extra="forbid"`: gửi thừa field sẽ bị 422 thay vì âm thầm bỏ qua, nhờ
26
+ vậy client gõ sai tên field biết ngay.
27
+ - `str_strip_whitespace`: cắt khoảng trắng thừa hai đầu chuỗi, tránh cảnh
28
+ `"An "` và `"An"` thành hai giá trị khác nhau trong database.
29
+ """
30
+
31
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
32
+
33
+
34
+ class OutputSchema(BaseModel):
35
+ """Nền cho mọi DTO trả về client.
36
+
37
+ `from_attributes` cho phép `model_validate()` đọc thẳng từ dataclass entity
38
+ hay object ORM, không phải chuyển sang dict trước.
39
+ """
40
+
41
+ model_config = ConfigDict(from_attributes=True)
42
+
43
+
44
+ def partial_of(model: type[ModelT], *, name: str | None = None) -> type[BaseModel]:
45
+ """Sinh bản sao của `model` với mọi field thành optional (mặc định None).
46
+
47
+ Dùng cho PATCH: chỉ field nào client gửi lên mới bị ghi đè. Ràng buộc
48
+ validate (pattern, min_length, ge/le...) được giữ nguyên, nên không phải
49
+ chép lại lần thứ hai và không sợ hai bản lệch nhau khi sửa.
50
+
51
+ Cách dùng thường gặp — kế thừa để thêm field chỉ có ở PATCH:
52
+
53
+ class UserUpdate(partial_of(UserBase)):
54
+ is_active: bool | None = None
55
+
56
+ `name` là TUỲ CHỌN và hầu như không cần. Nó chỉ đặt tên cho class được sinh
57
+ ra, mà class đó là LỚP CHA ẩn khi bạn kế thừa — tên hiện trong OpenAPI là
58
+ tên lớp con của bạn. Chỉ truyền `name` khi dùng thẳng kết quả, vì lúc đó
59
+ tên sinh ra chính là tên schema client nhìn thấy:
60
+
61
+ DeviceFilter = partial_of(DeviceBase, name="DeviceFilter")
62
+ # không có name thì schema sẽ mang tên "DeviceBasePartial"
63
+
64
+ Đừng đặt `name` trùng tên lớp con — sẽ có hai class cùng tên, và nếu cả hai
65
+ cùng xuất hiện trong OpenAPI thì FastAPI phải tự thêm tiền tố để phân biệt.
66
+ """
67
+ fields: dict[str, Any] = {}
68
+
69
+ for field_name, field in model.model_fields.items():
70
+ optional = copy.deepcopy(field)
71
+ optional.default = None
72
+ optional.default_factory = None
73
+ fields[field_name] = (field.annotation | None, optional)
74
+
75
+ # Kế thừa chính `model` chứ không phải lớp cha của nó: nhờ vậy field
76
+ # validator, model validator và model_config tuỳ chỉnh đều được giữ. Field
77
+ # bắt buộc bị ghi đè thành optional ngay bên dưới nên không mâu thuẫn.
78
+ return create_model( # type: ignore[call-overload]
79
+ name or f"{model.__name__}Partial",
80
+ __base__=model,
81
+ __doc__=f"Bản PATCH của {model.__name__}: mọi field optional.",
82
+ **fields,
83
+ )
84
+
85
+
86
+ def apply_changes(entity: Any, payload: BaseModel) -> list[str]:
87
+ """Chép các field client THỰC SỰ gửi lên vào entity. Trả về tên field đã đổi.
88
+
89
+ `exclude_unset=True` là mấu chốt: nó phân biệt "không gửi field này" với
90
+ "gửi field này = null". Thiếu nó thì PATCH một field sẽ xoá trắng các field
91
+ còn lại.
92
+ """
93
+ changes = payload.model_dump(exclude_unset=True)
94
+ for field_name, value in changes.items():
95
+ setattr(entity, field_name, value)
96
+ return sorted(changes)
97
+
98
+
99
+ class Page(BaseModel, Generic[T]):
100
+ """Bao ngoài cho danh sách có phân trang."""
101
+
102
+ items: list[T]
103
+ total: int
104
+ limit: int
105
+ offset: int
@@ -0,0 +1,31 @@
1
+ """Thư viện WebSocket của khung: gateway, phòng, gửi thẳng cho một client.
2
+
3
+ Đọc `docs/websocket.md` để có hướng dẫn đầy đủ (kèm ví dụ Postman và Next.js).
4
+
5
+ from pymodular.core.websocket import Socket, WebSocketServer, gateway, subscribe
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pymodular.core.websocket.adapter import BroadcastAdapter, LocalAdapter, RedisAdapter
11
+ from pymodular.core.websocket.gateway import build_ws_router, gateway, gateways_in, subscribe
12
+ from pymodular.core.websocket.namespace import Namespace
13
+ from pymodular.core.websocket.protocol import CloseCode, Frame, ProtocolError
14
+ from pymodular.core.websocket.server import WebSocketServer
15
+ from pymodular.core.websocket.socket import Socket
16
+
17
+ __all__ = [
18
+ "BroadcastAdapter",
19
+ "CloseCode",
20
+ "Frame",
21
+ "LocalAdapter",
22
+ "Namespace",
23
+ "ProtocolError",
24
+ "RedisAdapter",
25
+ "Socket",
26
+ "WebSocketServer",
27
+ "build_ws_router",
28
+ "gateway",
29
+ "gateways_in",
30
+ "subscribe",
31
+ ]
@@ -0,0 +1,192 @@
1
+ """Cầu nối giữa các tiến trình worker.
2
+
3
+ Vấn đề: sổ kết nối nằm trong BỘ NHỚ của một tiến trình. `pym run --workers 4` chạy 4
4
+ worker, mỗi worker là một tiến trình riêng. Client A nối vào worker 1, client B
5
+ nối vào worker 3; A gửi tin cho phòng "alerts" thì worker 1 chỉ thấy các kết
6
+ nối của chính nó — B không nhận được gì. Bệnh này chỉ lộ ra khi lên nhiều
7
+ worker/nhiều máy, nên rất hay bị phát hiện muộn.
8
+
9
+ Cách chữa: mỗi lần phát tin, ngoài việc gửi cho kết nối tại chỗ thì còn đăng
10
+ tin lên một kênh chung; các worker khác nghe kênh đó và gửi tiếp cho kết nối
11
+ của mình. Đây đúng là vai trò của Redis adapter trong NestJS.
12
+
13
+ Hai lựa chọn:
14
+
15
+ - `local` (mặc định): không có kênh chung. Đúng khi chạy MỘT worker. Nhanh
16
+ nhất, không phụ thuộc gì thêm.
17
+ - `redis`: dùng Redis pub/sub. Bật bằng `APP_WS__ADAPTER=redis`, cài bằng
18
+ `pip install 'fastapi-modular[redis]'`. Thư viện redis chỉ được import khi thật sự chọn nó.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import contextlib
25
+ import json
26
+ import os
27
+ import uuid
28
+ from collections.abc import Callable, Iterable
29
+ from typing import Any, Protocol
30
+
31
+ from pymodular.core.exceptions import ComponentNotEnabledError
32
+ from pymodular.core.logging import get_logger
33
+
34
+ log = get_logger(__name__)
35
+
36
+ OnMessage = Callable[[dict[str, Any]], None]
37
+
38
+
39
+ def new_origin_id() -> str:
40
+ """Mã nhận dạng worker này — để không nhận lại chính tin mình vừa đăng."""
41
+ return f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
42
+
43
+
44
+ def envelope(
45
+ origin: str,
46
+ namespace: str,
47
+ event: str,
48
+ data: Any,
49
+ *,
50
+ room: str | None = None,
51
+ user: str | None = None,
52
+ socket: str | None = None,
53
+ exclude: Iterable[str] = (),
54
+ ) -> dict[str, Any]:
55
+ return {
56
+ "origin": origin,
57
+ "ns": namespace,
58
+ "event": event,
59
+ "data": data,
60
+ "room": room,
61
+ "user": user,
62
+ "socket": socket,
63
+ "exclude": list(exclude),
64
+ }
65
+
66
+
67
+ class BroadcastAdapter(Protocol):
68
+ name: str
69
+
70
+ async def start(self, on_message: OnMessage) -> None: ...
71
+ async def publish(self, payload: dict[str, Any]) -> None: ...
72
+ async def stop(self) -> None: ...
73
+
74
+
75
+ class LocalAdapter:
76
+ """Không làm gì cả — mọi kết nối đều nằm trong tiến trình này."""
77
+
78
+ name = "local"
79
+
80
+ async def start(self, on_message: OnMessage) -> None:
81
+ return None
82
+
83
+ async def publish(self, payload: dict[str, Any]) -> None:
84
+ return None
85
+
86
+ async def stop(self) -> None:
87
+ return None
88
+
89
+
90
+ class RedisAdapter:
91
+ """Phát tin xuyên worker qua Redis pub/sub.
92
+
93
+ Cố ý dùng pub/sub chứ không phải stream: tin nhắn realtime hết giá trị sau
94
+ vài giây, không đáng để lưu lại. Worker vừa khởi động sẽ không nhận được
95
+ tin phát lúc nó chưa lên — đúng như mong đợi.
96
+ """
97
+
98
+ name = "redis"
99
+
100
+ def __init__(self, url: str, channel: str, *, origin: str | None = None) -> None:
101
+ self.url = url
102
+ self.channel = channel
103
+ self.origin = origin or new_origin_id()
104
+ self._client: Any = None
105
+ self._pubsub: Any = None
106
+ self._task: asyncio.Task[None] | None = None
107
+ self._on_message: OnMessage | None = None
108
+ self._stopping = False
109
+
110
+ async def start(self, on_message: OnMessage) -> None:
111
+ try:
112
+ import redis.asyncio as redis
113
+ except ModuleNotFoundError as exc:
114
+ raise ComponentNotEnabledError(
115
+ "APP_WS__ADAPTER=redis nhưng chưa cài thư viện redis. "
116
+ "Chạy `pip install 'fastapi-modular[redis]'`, hoặc đổi về APP_WS__ADAPTER=local nếu "
117
+ "chỉ chạy một worker."
118
+ ) from exc
119
+
120
+ self._on_message = on_message
121
+ self._client = redis.from_url(self.url, decode_responses=True)
122
+ await self._client.ping() # hỏng cấu hình thì báo ngay lúc boot
123
+ self._task = asyncio.create_task(self._listen(), name="ws-redis-rabbitmq_subscriber")
124
+ log.info("ws.adapter_started", adapter=self.name, channel=self.channel, origin=self.origin)
125
+
126
+ async def publish(self, payload: dict[str, Any]) -> None:
127
+ if self._client is None:
128
+ return
129
+ try:
130
+ await self._client.publish(self.channel, json.dumps(payload, default=str))
131
+ except Exception as exc: # noqa: BLE001 - Redis hỏng không được làm hỏng request
132
+ # Tin đã tới được các kết nối tại chỗ; mất phần xuyên worker thôi.
133
+ log.warning("ws.publish_failed", error=f"{type(exc).__name__}: {exc}")
134
+
135
+ async def _listen(self) -> None:
136
+ delay = 0.5
137
+ while not self._stopping:
138
+ try:
139
+ self._pubsub = self._client.pubsub()
140
+ await self._pubsub.subscribe(self.channel)
141
+ delay = 0.5
142
+ async for message in self._pubsub.listen():
143
+ if message.get("type") != "message":
144
+ continue
145
+ self._dispatch(message.get("data"))
146
+ except asyncio.CancelledError:
147
+ raise
148
+ except Exception as exc: # noqa: BLE001 - mất Redis phải tự nối lại, không được chết task
149
+ if self._stopping:
150
+ return
151
+ log.warning(
152
+ "ws.adapter_reconnecting",
153
+ error=f"{type(exc).__name__}: {exc}",
154
+ retry_in=delay,
155
+ )
156
+ await asyncio.sleep(delay)
157
+ delay = min(delay * 2, 10.0)
158
+
159
+ def _dispatch(self, raw: Any) -> None:
160
+ try:
161
+ payload = json.loads(raw)
162
+ except (TypeError, ValueError):
163
+ log.warning("ws.adapter_bad_payload")
164
+ return
165
+ if payload.get("origin") == self.origin:
166
+ return # tin của chính mình, đã gửi tại chỗ rồi
167
+ if self._on_message is not None:
168
+ self._on_message(payload)
169
+
170
+ async def stop(self) -> None:
171
+ # Đang tắt tiến trình: mọi lỗi dọn dẹp đều nuốt, vì báo lỗi lúc này
172
+ # chẳng ai xử lý được mà lại che mất phần shutdown còn lại.
173
+ self._stopping = True
174
+ if self._task is not None:
175
+ self._task.cancel()
176
+ with contextlib.suppress(BaseException):
177
+ await self._task
178
+ self._task = None
179
+ if self._pubsub is not None:
180
+ with contextlib.suppress(Exception):
181
+ await self._pubsub.aclose()
182
+ self._pubsub = None
183
+ if self._client is not None:
184
+ with contextlib.suppress(Exception):
185
+ await self._client.aclose()
186
+ self._client = None
187
+
188
+
189
+ def build_adapter(kind: str, *, url: str, channel: str, origin: str) -> BroadcastAdapter:
190
+ if kind == "redis":
191
+ return RedisAdapter(url, channel, origin=origin)
192
+ return LocalAdapter()