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.
- fastapi_modular-0.1.0.dist-info/METADATA +377 -0
- fastapi_modular-0.1.0.dist-info/RECORD +69 -0
- fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
- fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
- pymodular/__init__.py +74 -0
- pymodular/cli/__init__.py +0 -0
- pymodular/cli/clean.py +39 -0
- pymodular/cli/configure_env.py +569 -0
- pymodular/cli/cong_cu.py +111 -0
- pymodular/cli/info.py +62 -0
- pymodular/cli/install.py +83 -0
- pymodular/cli/main.py +247 -0
- pymodular/cli/new_module.py +492 -0
- pymodular/cli/new_project.py +471 -0
- pymodular/cli/serve.py +59 -0
- pymodular/core/__init__.py +0 -0
- pymodular/core/clock.py +15 -0
- pymodular/core/compat.py +39 -0
- pymodular/core/config.py +495 -0
- pymodular/core/container.py +354 -0
- pymodular/core/context.py +78 -0
- pymodular/core/controller.py +208 -0
- pymodular/core/error_handlers.py +272 -0
- pymodular/core/exceptions.py +104 -0
- pymodular/core/guards.py +117 -0
- pymodular/core/lifespan.py +150 -0
- pymodular/core/logging.py +88 -0
- pymodular/core/metrics.py +190 -0
- pymodular/core/schemas.py +105 -0
- pymodular/core/websocket/__init__.py +31 -0
- pymodular/core/websocket/adapter.py +192 -0
- pymodular/core/websocket/gateway.py +735 -0
- pymodular/core/websocket/namespace.py +148 -0
- pymodular/core/websocket/protocol.py +157 -0
- pymodular/core/websocket/server.py +175 -0
- pymodular/core/websocket/socket.py +241 -0
- pymodular/discovery.py +180 -0
- pymodular/factory.py +126 -0
- pymodular/infrastructure/__init__.py +1 -0
- pymodular/infrastructure/database/__init__.py +8 -0
- pymodular/infrastructure/database/base.py +228 -0
- pymodular/infrastructure/database/circuit.py +207 -0
- pymodular/infrastructure/database/factory.py +88 -0
- pymodular/infrastructure/database/memory.py +112 -0
- pymodular/infrastructure/database/mongo.py +186 -0
- pymodular/infrastructure/database/repository.py +188 -0
- pymodular/infrastructure/database/sql.py +520 -0
- pymodular/infrastructure/kafka/__init__.py +26 -0
- pymodular/infrastructure/kafka/broker.py +231 -0
- pymodular/infrastructure/kafka/consumers.py +371 -0
- pymodular/infrastructure/kafka/metrics.py +17 -0
- pymodular/infrastructure/mqtt/__init__.py +35 -0
- pymodular/infrastructure/mqtt/client.py +292 -0
- pymodular/infrastructure/mqtt/consumers.py +219 -0
- pymodular/infrastructure/mqtt/metrics.py +17 -0
- pymodular/infrastructure/mqtt/patterns.py +116 -0
- pymodular/infrastructure/rabbitmq/__init__.py +33 -0
- pymodular/infrastructure/rabbitmq/broker.py +616 -0
- pymodular/infrastructure/rabbitmq/consumers.py +450 -0
- pymodular/infrastructure/rabbitmq/metrics.py +34 -0
- pymodular/infrastructure/rabbitmq/patterns.py +64 -0
- pymodular/infrastructure/redis/__init__.py +31 -0
- pymodular/infrastructure/redis/client.py +362 -0
- pymodular/infrastructure/redis/metrics.py +20 -0
- pymodular/infrastructure/redis/pubsub.py +262 -0
- pymodular/middleware/__init__.py +0 -0
- pymodular/middleware/request_context.py +164 -0
- pymodular/py.typed +0 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Ghi sẵn biến môi trường của thành phần vừa cài vào .env.
|
|
2
|
+
|
|
3
|
+
Chạy qua `pym env <thành-phần>` (chỉ ghi .env) hoặc `pym install <thành-phần>`
|
|
4
|
+
(cài thư viện rồi ghi .env).
|
|
5
|
+
|
|
6
|
+
Mỗi thành phần có một KHỐI riêng, đánh dấu giữa hai mốc BEGIN/END. Chỉ khối đó
|
|
7
|
+
bị đụng tới, nên mọi biến khác trong .env (APP_HOST, APP_PORT, khoá bí mật...)
|
|
8
|
+
được giữ nguyên. Chạy lại với driver khác sẽ THAY khối cũ chứ không chồng thêm.
|
|
9
|
+
Các khối độc lập nhau: đổi driver database không đụng tới khối rabbitmq.
|
|
10
|
+
|
|
11
|
+
Mỗi biến sinh ra luôn có ba thứ:
|
|
12
|
+
|
|
13
|
+
# <giải thích biến này làm gì>
|
|
14
|
+
# tuỳ chọn · mặc định: false <- xoá dòng dưới là quay về giá trị này
|
|
15
|
+
APP_RABBITMQ__ENABLED=true
|
|
16
|
+
|
|
17
|
+
Giá trị mặc định KHÔNG gõ tay: nó được đọc thẳng từ model Settings tương ứng,
|
|
18
|
+
nên không thể lệch với code. Khai một biến không còn tồn tại trong model thì
|
|
19
|
+
script báo lỗi ngay — đó cũng là cách bắt những biến đã bị xoá khỏi code mà
|
|
20
|
+
quên xoá ở đây.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
import textwrap
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel
|
|
31
|
+
|
|
32
|
+
from pymodular.core.config import (
|
|
33
|
+
DatabaseSettings,
|
|
34
|
+
KafkaSettings,
|
|
35
|
+
MqttSettings,
|
|
36
|
+
RabbitSettings,
|
|
37
|
+
RedisSettings,
|
|
38
|
+
WebSocketSettings,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
RONG = 78
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def begin_marker(section: str) -> str:
|
|
45
|
+
return f"# >>> {section} (sinh bởi pym env) >>>"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _moc_cu(section: str) -> str:
|
|
49
|
+
"""Mốc thời CLI còn là `make install-*`.
|
|
50
|
+
|
|
51
|
+
Vẫn phải nhận ra, để `pym env` THAY đúng khối trong .env đã có thay vì ghi
|
|
52
|
+
thêm một khối thứ hai bên dưới — người dùng sẽ có hai `APP_DB__DRIVER` và
|
|
53
|
+
cái nằm dưới lặng lẽ thắng.
|
|
54
|
+
"""
|
|
55
|
+
return f"# >>> {section} (sinh bởi make install-*) >>>"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def end_marker(section: str) -> str:
|
|
59
|
+
return f"# <<< {section} <<<"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Bien:
|
|
64
|
+
"""Một biến môi trường sẽ được ghi ra .env."""
|
|
65
|
+
|
|
66
|
+
key: str
|
|
67
|
+
value: str
|
|
68
|
+
mo_ta: str
|
|
69
|
+
bat_buoc: bool = False
|
|
70
|
+
"""True = xoá dòng này đi thì app chạy SAI một cách im lặng.
|
|
71
|
+
|
|
72
|
+
Không có nghĩa là "không có mặc định" — `APP_DB__DRIVER` có mặc định
|
|
73
|
+
(`memory`), nhưng xoá nó sau khi cài SQLite thì dữ liệu bốc hơi mỗi lần
|
|
74
|
+
restart mà không ai báo gì.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Khoi:
|
|
80
|
+
"""Một khối cấu hình: thuộc mục nào, đọc mặc định từ model nào."""
|
|
81
|
+
|
|
82
|
+
section: str
|
|
83
|
+
model: type[BaseModel]
|
|
84
|
+
prefix: str
|
|
85
|
+
items: list[Bien | str]
|
|
86
|
+
"""Phần tử là chuỗi thì in ra làm tiêu đề nhóm."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _mac_dinh(khoi: Khoi, key: str) -> str:
|
|
90
|
+
"""Đọc giá trị mặc định từ model, để .env và code không bao giờ lệch nhau."""
|
|
91
|
+
ten_truong = key.removeprefix(khoi.prefix).lower()
|
|
92
|
+
field = khoi.model.model_fields.get(ten_truong)
|
|
93
|
+
if field is None:
|
|
94
|
+
raise KeyError(
|
|
95
|
+
f"{key} không còn tồn tại trong {khoi.model.__name__}. "
|
|
96
|
+
"Xoá nó khỏi pymodular/cli/configure_env.py, hoặc thêm lại trường vào model."
|
|
97
|
+
)
|
|
98
|
+
default = field.default
|
|
99
|
+
if default is None and ten_truong == "dsn":
|
|
100
|
+
# DSN không có mặc định tĩnh: nó được suy ra từ driver lúc chạy. Lấy
|
|
101
|
+
# đúng giá trị đó thay vì nói "(trống)" — người đọc cần biết app sẽ nối
|
|
102
|
+
# vào đâu nếu họ xoá dòng này.
|
|
103
|
+
driver = next(
|
|
104
|
+
(b.value for b in khoi.items if isinstance(b, Bien) and b.key.endswith("__DRIVER")),
|
|
105
|
+
"memory",
|
|
106
|
+
)
|
|
107
|
+
return DatabaseSettings(driver=driver).resolved_dsn or "(trống)"
|
|
108
|
+
if default is None:
|
|
109
|
+
return "(trống)"
|
|
110
|
+
if isinstance(default, bool):
|
|
111
|
+
return "true" if default else "false"
|
|
112
|
+
if isinstance(default, str):
|
|
113
|
+
return default or "(trống)"
|
|
114
|
+
return str(default)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def render(khoi: Khoi) -> str:
|
|
118
|
+
"""Dựng nội dung khối .env, mỗi biến kèm giải thích và mặc định."""
|
|
119
|
+
dong: list[str] = []
|
|
120
|
+
for item in khoi.items:
|
|
121
|
+
if isinstance(item, str):
|
|
122
|
+
dong.append("" if not item else f"# --- {item} ---")
|
|
123
|
+
continue
|
|
124
|
+
dong.extend(f"# {d}" for d in textwrap.wrap(item.mo_ta, RONG - 2))
|
|
125
|
+
if item.bat_buoc:
|
|
126
|
+
canh_bao = (
|
|
127
|
+
f"BẮT BUỘC — xoá dòng này thì app quay về {_mac_dinh(khoi, item.key)}, "
|
|
128
|
+
"gần như chắc chắn không phải thứ bạn muốn"
|
|
129
|
+
)
|
|
130
|
+
dong.extend(f"# {d}" for d in textwrap.wrap(canh_bao, RONG - 2))
|
|
131
|
+
else:
|
|
132
|
+
dong.append(f"# tuỳ chọn · mặc định: {_mac_dinh(khoi, item.key)}")
|
|
133
|
+
dong.append(f"{item.key}={item.value}")
|
|
134
|
+
return "\n".join(dong)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------- các khối
|
|
138
|
+
BLOCKS: dict[str, Khoi] = {
|
|
139
|
+
"sqlite": Khoi(
|
|
140
|
+
"database",
|
|
141
|
+
DatabaseSettings,
|
|
142
|
+
"APP_DB__",
|
|
143
|
+
[
|
|
144
|
+
Bien(
|
|
145
|
+
"APP_DB__DRIVER",
|
|
146
|
+
"sqlite",
|
|
147
|
+
"Backend database đang dùng. Xoá dòng này thì app chạy bằng bộ nhớ tạm "
|
|
148
|
+
"và mất sạch dữ liệu mỗi lần restart.",
|
|
149
|
+
bat_buoc=True,
|
|
150
|
+
),
|
|
151
|
+
Bien(
|
|
152
|
+
"APP_DB__DSN",
|
|
153
|
+
"sqlite+aiosqlite:///./data/app.db",
|
|
154
|
+
"Đường dẫn file .db. Thư mục chứa nó phải tồn tại — lệnh này tự tạo ./data.",
|
|
155
|
+
),
|
|
156
|
+
Bien(
|
|
157
|
+
"APP_DB__SCHEMA_MODE",
|
|
158
|
+
"create",
|
|
159
|
+
"Mức tự chỉnh schema lúc khởi động. off = không đụng gì (dùng cho "
|
|
160
|
+
"production kèm Alembic) | create = chỉ tạo bảng còn thiếu | "
|
|
161
|
+
"sync = thêm cột mới theo entity và báo cột thừa (chỉ dev).",
|
|
162
|
+
),
|
|
163
|
+
Bien(
|
|
164
|
+
"APP_DB__DROP_COLUMNS",
|
|
165
|
+
"false",
|
|
166
|
+
"Cho phép sync XOÁ cột không còn trong entity. Xoá cột là mất dữ liệu.",
|
|
167
|
+
),
|
|
168
|
+
Bien("APP_DB__ECHO", "false", "In câu SQL ra log khi cần soi."),
|
|
169
|
+
],
|
|
170
|
+
),
|
|
171
|
+
"postgres": Khoi(
|
|
172
|
+
"database",
|
|
173
|
+
DatabaseSettings,
|
|
174
|
+
"APP_DB__",
|
|
175
|
+
[
|
|
176
|
+
Bien(
|
|
177
|
+
"APP_DB__DRIVER",
|
|
178
|
+
"postgres",
|
|
179
|
+
"Backend database đang dùng. Xoá dòng này thì app chạy bằng bộ nhớ tạm "
|
|
180
|
+
"và mất sạch dữ liệu mỗi lần restart.",
|
|
181
|
+
bat_buoc=True,
|
|
182
|
+
),
|
|
183
|
+
Bien(
|
|
184
|
+
"APP_DB__DSN",
|
|
185
|
+
"postgresql+asyncpg://postgres:postgres@localhost:5432/app",
|
|
186
|
+
"Dạng postgresql+asyncpg://NGƯỜI_DÙNG:MẬT_KHẨU@HOST:CỔNG/TÊN_DB.",
|
|
187
|
+
bat_buoc=True,
|
|
188
|
+
),
|
|
189
|
+
Bien(
|
|
190
|
+
"APP_DB__SCHEMA_MODE",
|
|
191
|
+
"create",
|
|
192
|
+
"off | create | sync — xem docs/database.md.",
|
|
193
|
+
),
|
|
194
|
+
Bien(
|
|
195
|
+
"APP_DB__DROP_COLUMNS",
|
|
196
|
+
"false",
|
|
197
|
+
"Cho phép sync XOÁ cột không còn trong entity. Xoá cột là mất dữ liệu.",
|
|
198
|
+
),
|
|
199
|
+
Bien("APP_DB__ECHO", "false", "In câu SQL ra log khi cần soi."),
|
|
200
|
+
"kết nối & phục hồi khi database rớt",
|
|
201
|
+
Bien(
|
|
202
|
+
"APP_DB__POOL_PRE_PING",
|
|
203
|
+
"true",
|
|
204
|
+
"Thử connection còn sống trước khi giao cho request. Tắt đi thì mỗi "
|
|
205
|
+
"lần database restart sẽ có đúng một request lỗi.",
|
|
206
|
+
),
|
|
207
|
+
Bien(
|
|
208
|
+
"APP_DB__POOL_SIZE",
|
|
209
|
+
"5",
|
|
210
|
+
"Trần connection = (POOL_SIZE + MAX_OVERFLOW) x số worker. Mặc định "
|
|
211
|
+
"15 mỗi worker; Postgres cho tối đa 100 nên trên 6 worker phải giảm.",
|
|
212
|
+
),
|
|
213
|
+
Bien("APP_DB__MAX_OVERFLOW", "10", "Số connection mượn thêm lúc cao điểm."),
|
|
214
|
+
Bien(
|
|
215
|
+
"APP_DB__POOL_RECYCLE_SECONDS",
|
|
216
|
+
"1800",
|
|
217
|
+
"Mở lại connection cũ hơn ngần này giây; proxy hay cắt kết nối nhàn rỗi.",
|
|
218
|
+
),
|
|
219
|
+
Bien("APP_DB__CONNECT_TIMEOUT_SECONDS", "10", "Chờ tối đa khi MỞ kết nối."),
|
|
220
|
+
Bien(
|
|
221
|
+
"APP_DB__QUERY_TIMEOUT_SECONDS",
|
|
222
|
+
"15",
|
|
223
|
+
"Chờ tối đa cho MỘT câu truy vấn đã gửi đi. Khác CONNECT_TIMEOUT: "
|
|
224
|
+
"database treo giữa chừng thì connection vẫn mở.",
|
|
225
|
+
),
|
|
226
|
+
Bien(
|
|
227
|
+
"APP_DB__STARTUP_RETRIES",
|
|
228
|
+
"5",
|
|
229
|
+
"Thử lại mấy lần khi khởi động mà database chưa sẵn sàng.",
|
|
230
|
+
),
|
|
231
|
+
Bien("APP_DB__STARTUP_RETRY_DELAY_SECONDS", "1", "Chờ giữa các lần thử."),
|
|
232
|
+
"ngắt mạch khi database hỏng",
|
|
233
|
+
Bien(
|
|
234
|
+
"APP_DB__CIRCUIT_BREAKER",
|
|
235
|
+
"true",
|
|
236
|
+
"Hỏng liên tiếp quá ngưỡng thì trả 503 ngay, không chạm database nữa.",
|
|
237
|
+
),
|
|
238
|
+
Bien("APP_DB__CIRCUIT_FAILURE_THRESHOLD", "5", "Số lần hỏng liên tiếp để ngắt."),
|
|
239
|
+
Bien("APP_DB__CIRCUIT_RESET_SECONDS", "10", "Bao lâu thì thử đóng mạch lại."),
|
|
240
|
+
],
|
|
241
|
+
),
|
|
242
|
+
"mongodb": Khoi(
|
|
243
|
+
"database",
|
|
244
|
+
DatabaseSettings,
|
|
245
|
+
"APP_DB__",
|
|
246
|
+
[
|
|
247
|
+
Bien(
|
|
248
|
+
"APP_DB__DRIVER",
|
|
249
|
+
"mongodb",
|
|
250
|
+
"Backend database đang dùng. Xoá dòng này thì app chạy bằng bộ nhớ tạm "
|
|
251
|
+
"và mất sạch dữ liệu mỗi lần restart.",
|
|
252
|
+
bat_buoc=True,
|
|
253
|
+
),
|
|
254
|
+
Bien(
|
|
255
|
+
"APP_DB__DSN",
|
|
256
|
+
"mongodb://localhost:27017",
|
|
257
|
+
"Dạng mongodb://HOST:CỔNG hoặc mongodb+srv://NGƯỜI_DÙNG:MẬT_KHẨU@CỤM.",
|
|
258
|
+
bat_buoc=True,
|
|
259
|
+
),
|
|
260
|
+
Bien(
|
|
261
|
+
"APP_DB__NAME",
|
|
262
|
+
"app",
|
|
263
|
+
"Tên database bên trong Mongo. Collection lấy theo tên entity.",
|
|
264
|
+
),
|
|
265
|
+
Bien(
|
|
266
|
+
"APP_DB__CONNECT_TIMEOUT_SECONDS",
|
|
267
|
+
"10",
|
|
268
|
+
"Hạn chọn server. Mặc định của driver là 30s, quá lâu cho một request.",
|
|
269
|
+
),
|
|
270
|
+
Bien("APP_DB__QUERY_TIMEOUT_SECONDS", "15", "Hạn cho MỘT câu truy vấn."),
|
|
271
|
+
Bien("APP_DB__STARTUP_RETRIES", "5", "Thử lại khi khởi động mà database chưa lên."),
|
|
272
|
+
Bien("APP_DB__STARTUP_RETRY_DELAY_SECONDS", "1", "Chờ giữa các lần thử."),
|
|
273
|
+
"ngắt mạch khi database hỏng",
|
|
274
|
+
Bien("APP_DB__CIRCUIT_BREAKER", "true", "Hỏng liên tiếp quá ngưỡng thì trả 503 ngay."),
|
|
275
|
+
Bien("APP_DB__CIRCUIT_FAILURE_THRESHOLD", "5", "Số lần hỏng liên tiếp để ngắt."),
|
|
276
|
+
Bien("APP_DB__CIRCUIT_RESET_SECONDS", "10", "Bao lâu thì thử đóng mạch lại."),
|
|
277
|
+
],
|
|
278
|
+
),
|
|
279
|
+
"ws-redis": Khoi(
|
|
280
|
+
"websocket",
|
|
281
|
+
WebSocketSettings,
|
|
282
|
+
"APP_WS__",
|
|
283
|
+
[
|
|
284
|
+
Bien(
|
|
285
|
+
"APP_WS__ADAPTER",
|
|
286
|
+
"redis",
|
|
287
|
+
"Cách phát tin WebSocket xuyên worker. local = mỗi worker một sổ kết "
|
|
288
|
+
"nối riêng, chỉ đúng khi chạy MỘT worker. redis = mọi worker cùng nhận.",
|
|
289
|
+
),
|
|
290
|
+
Bien("APP_WS__REDIS_URL", "redis://localhost:6379/0", "Redis dùng làm kênh chung."),
|
|
291
|
+
Bien(
|
|
292
|
+
"APP_WS__CHANNEL",
|
|
293
|
+
"ws:broadcast",
|
|
294
|
+
"Kênh pub/sub. Nhiều ứng dụng chung một Redis thì đặt tên khác nhau.",
|
|
295
|
+
),
|
|
296
|
+
"giới hạn cho mỗi kết nối",
|
|
297
|
+
Bien(
|
|
298
|
+
"APP_WS__SEND_QUEUE_SIZE",
|
|
299
|
+
"100",
|
|
300
|
+
"Trần số tin chờ gửi. Client đọc chậm mà vượt trần thì bị ngắt, thay vì "
|
|
301
|
+
"để hàng đợi phình tới lúc hết RAM.",
|
|
302
|
+
),
|
|
303
|
+
Bien(
|
|
304
|
+
"APP_WS__OVERFLOW",
|
|
305
|
+
"close",
|
|
306
|
+
"Khi hàng đợi đầy: close = ngắt client chậm | drop_oldest = bỏ tin cũ "
|
|
307
|
+
"(hợp với dữ liệu chỉ cần bản mới nhất như vị trí, nhiệt độ).",
|
|
308
|
+
),
|
|
309
|
+
Bien(
|
|
310
|
+
"APP_WS__HEARTBEAT_SECONDS",
|
|
311
|
+
"25",
|
|
312
|
+
"Chu kỳ server gửi ping. Để dưới 30s vì nhiều proxy cắt kết nối nhàn rỗi ~60s.",
|
|
313
|
+
),
|
|
314
|
+
Bien(
|
|
315
|
+
"APP_WS__IDLE_TIMEOUT_SECONDS",
|
|
316
|
+
"70",
|
|
317
|
+
"Không nhận được khung nào trong ngần này giây thì đóng — cách duy nhất "
|
|
318
|
+
"phát hiện client đã chết mà TCP chưa biết.",
|
|
319
|
+
),
|
|
320
|
+
Bien("APP_WS__MAX_MESSAGE_BYTES", "65536", "Khung tin dài hơn bị từ chối."),
|
|
321
|
+
Bien("APP_WS__MAX_MESSAGES_PER_SECOND", "50", "Trần tần suất mỗi kết nối; 0 để tắt."),
|
|
322
|
+
Bien("APP_WS__MAX_CONNECTIONS", "5000", "Trần kết nối mỗi worker."),
|
|
323
|
+
Bien(
|
|
324
|
+
"APP_WS__MAX_CONNECTIONS_PER_USER",
|
|
325
|
+
"10",
|
|
326
|
+
"Trần kết nối đồng thời của một tài khoản; 0 để tắt.",
|
|
327
|
+
),
|
|
328
|
+
],
|
|
329
|
+
),
|
|
330
|
+
"redis": Khoi(
|
|
331
|
+
"redis",
|
|
332
|
+
RedisSettings,
|
|
333
|
+
"APP_REDIS__",
|
|
334
|
+
[
|
|
335
|
+
Bien(
|
|
336
|
+
"APP_REDIS__ENABLED",
|
|
337
|
+
"true",
|
|
338
|
+
"Bật/tắt lớp Redis (cache, đếm, pub/sub). Đặt false thì phần còn lại "
|
|
339
|
+
"của app chạy bình thường, không cần gỡ thư viện hay sửa code. Không "
|
|
340
|
+
"liên quan tới APP_WS__ADAPTER — adapter WebSocket có cấu hình riêng.",
|
|
341
|
+
),
|
|
342
|
+
Bien(
|
|
343
|
+
"APP_REDIS__URL",
|
|
344
|
+
"redis://localhost:6379/0",
|
|
345
|
+
"Dạng redis://[:MẬT_KHẨU@]HOST:CỔNG/SỐ_DB, hoặc rediss:// nếu có TLS.",
|
|
346
|
+
bat_buoc=True,
|
|
347
|
+
),
|
|
348
|
+
Bien(
|
|
349
|
+
"APP_REDIS__KEY_PREFIX",
|
|
350
|
+
"",
|
|
351
|
+
"Tiền tố ghép vào mọi khoá và mọi kênh. Nhiều ứng dụng dùng chung một "
|
|
352
|
+
"Redis thì đặt khác nhau để không ai ghi đè khoá của ai.",
|
|
353
|
+
),
|
|
354
|
+
"thời gian chờ",
|
|
355
|
+
Bien(
|
|
356
|
+
"APP_REDIS__CONNECT_TIMEOUT_SECONDS",
|
|
357
|
+
"5",
|
|
358
|
+
"Chờ mở kết nối. Hết giờ thì app vẫn chạy và nối lại ngầm.",
|
|
359
|
+
),
|
|
360
|
+
Bien(
|
|
361
|
+
"APP_REDIS__COMMAND_TIMEOUT_SECONDS",
|
|
362
|
+
"5",
|
|
363
|
+
"Trần thời gian cho MỘT lệnh. Redis chậm còn tệ hơn Redis chết: không "
|
|
364
|
+
"có trần thì mọi request đang chờ cache sẽ treo theo.",
|
|
365
|
+
),
|
|
366
|
+
"tự nối lại",
|
|
367
|
+
Bien(
|
|
368
|
+
"APP_REDIS__RECONNECT_DELAY_SECONDS",
|
|
369
|
+
"1",
|
|
370
|
+
"Chờ trước lần thử lại đầu tiên; các lần sau tăng gấp đôi.",
|
|
371
|
+
),
|
|
372
|
+
Bien(
|
|
373
|
+
"APP_REDIS__MAX_RECONNECT_DELAY_SECONDS",
|
|
374
|
+
"30",
|
|
375
|
+
"Trần thời gian chờ giữa hai lần thử. Có trần thì server hồi sinh sau "
|
|
376
|
+
"nhiều giờ vẫn được nối lại trong vòng ngần này giây.",
|
|
377
|
+
),
|
|
378
|
+
],
|
|
379
|
+
),
|
|
380
|
+
"mqtt": Khoi(
|
|
381
|
+
"mqtt",
|
|
382
|
+
MqttSettings,
|
|
383
|
+
"APP_MQTT__",
|
|
384
|
+
[
|
|
385
|
+
Bien(
|
|
386
|
+
"APP_MQTT__ENABLED",
|
|
387
|
+
"true",
|
|
388
|
+
"Bật/tắt lớp MQTT. Đặt false thì phần còn lại của app chạy bình thường.",
|
|
389
|
+
),
|
|
390
|
+
Bien(
|
|
391
|
+
"APP_MQTT__URL",
|
|
392
|
+
"mqtt://localhost:1883",
|
|
393
|
+
"Dạng mqtt://[NGƯỜI_DÙNG:MẬT_KHẨU@]HOST:CỔNG, hoặc mqtts:// nếu có TLS.",
|
|
394
|
+
bat_buoc=True,
|
|
395
|
+
),
|
|
396
|
+
"phiên làm việc",
|
|
397
|
+
Bien(
|
|
398
|
+
"APP_MQTT__CLIENT_ID",
|
|
399
|
+
"",
|
|
400
|
+
"Danh tính phiên trên broker. Để trống thì sinh ngẫu nhiên mỗi lần chạy. "
|
|
401
|
+
"Chạy nhiều worker thì mỗi worker phải một id khác nhau — trùng id là "
|
|
402
|
+
"hai bên đá nhau ra khỏi broker liên tục.",
|
|
403
|
+
),
|
|
404
|
+
Bien(
|
|
405
|
+
"APP_MQTT__CLEAN_SESSION",
|
|
406
|
+
"true",
|
|
407
|
+
"false = broker GIỮ tin QoS>=1 lại trong lúc client ngắt và giao tiếp khi "
|
|
408
|
+
"nối lại; cần đi kèm CLIENT_ID cố định. true = mất tin trong lúc ngắt.",
|
|
409
|
+
),
|
|
410
|
+
Bien(
|
|
411
|
+
"APP_MQTT__KEEPALIVE_SECONDS",
|
|
412
|
+
"30",
|
|
413
|
+
"Nhịp tim MQTT. Đây là thứ duy nhất phát hiện được cảnh rút cáp hay mất "
|
|
414
|
+
"điện, vì lúc đó không có gói ngắt kết nối nào được gửi đi.",
|
|
415
|
+
),
|
|
416
|
+
"tự nối lại",
|
|
417
|
+
Bien("APP_MQTT__CONNECT_TIMEOUT_SECONDS", "10", "Chờ lần bắt tay đầu tiên."),
|
|
418
|
+
Bien(
|
|
419
|
+
"APP_MQTT__RECONNECT_DELAY_SECONDS",
|
|
420
|
+
"1",
|
|
421
|
+
"Chờ trước lần thử lại đầu tiên; các lần sau tăng gấp đôi.",
|
|
422
|
+
),
|
|
423
|
+
Bien(
|
|
424
|
+
"APP_MQTT__MAX_RECONNECT_DELAY_SECONDS",
|
|
425
|
+
"30",
|
|
426
|
+
"Trần thời gian chờ giữa hai lần thử. Có trần thì server hồi sinh sau "
|
|
427
|
+
"nhiều giờ vẫn được nối lại trong vòng ngần này giây.",
|
|
428
|
+
),
|
|
429
|
+
],
|
|
430
|
+
),
|
|
431
|
+
"kafka": Khoi(
|
|
432
|
+
"kafka",
|
|
433
|
+
KafkaSettings,
|
|
434
|
+
"APP_KAFKA__",
|
|
435
|
+
[
|
|
436
|
+
Bien(
|
|
437
|
+
"APP_KAFKA__ENABLED",
|
|
438
|
+
"true",
|
|
439
|
+
"Bật/tắt lớp Kafka. Đặt false thì phần còn lại của app chạy bình thường.",
|
|
440
|
+
),
|
|
441
|
+
Bien(
|
|
442
|
+
"APP_KAFKA__BOOTSTRAP_SERVERS",
|
|
443
|
+
"localhost:9092",
|
|
444
|
+
"Danh sách HOST:CỔNG ngăn bằng dấu phẩy. Chỉ cần vài broker để hỏi "
|
|
445
|
+
"đường; client tự tìm ra phần còn lại của cụm.",
|
|
446
|
+
bat_buoc=True,
|
|
447
|
+
),
|
|
448
|
+
Bien(
|
|
449
|
+
"APP_KAFKA__CLIENT_ID",
|
|
450
|
+
"pymodular",
|
|
451
|
+
"Tên ứng dụng hiện trong log và số đo của cụm Kafka.",
|
|
452
|
+
),
|
|
453
|
+
Bien(
|
|
454
|
+
"APP_KAFKA__ACKS",
|
|
455
|
+
"all",
|
|
456
|
+
"Bao nhiêu bản sao phải ghi xong mới coi là gửi thành công. "
|
|
457
|
+
"all = an toàn nhất (chậm hơn) | 1 = chỉ leader | 0 = bắn đi rồi thôi.",
|
|
458
|
+
),
|
|
459
|
+
"thời gian chờ",
|
|
460
|
+
Bien("APP_KAFKA__REQUEST_TIMEOUT_SECONDS", "20", "Trần cho một lần gửi/nhận."),
|
|
461
|
+
Bien("APP_KAFKA__CONNECT_TIMEOUT_SECONDS", "10", "Chờ lần nối đầu tiên."),
|
|
462
|
+
"tự nối lại",
|
|
463
|
+
Bien(
|
|
464
|
+
"APP_KAFKA__RECONNECT_DELAY_SECONDS",
|
|
465
|
+
"1",
|
|
466
|
+
"Chờ trước lần thử lại đầu tiên; các lần sau tăng gấp đôi.",
|
|
467
|
+
),
|
|
468
|
+
Bien(
|
|
469
|
+
"APP_KAFKA__MAX_RECONNECT_DELAY_SECONDS",
|
|
470
|
+
"30",
|
|
471
|
+
"Trần thời gian chờ giữa hai lần thử. Có trần thì server hồi sinh sau "
|
|
472
|
+
"nhiều giờ vẫn được nối lại trong vòng ngần này giây.",
|
|
473
|
+
),
|
|
474
|
+
],
|
|
475
|
+
),
|
|
476
|
+
"rabbitmq": Khoi(
|
|
477
|
+
"rabbitmq",
|
|
478
|
+
RabbitSettings,
|
|
479
|
+
"APP_RABBITMQ__",
|
|
480
|
+
[
|
|
481
|
+
Bien(
|
|
482
|
+
"APP_RABBITMQ__ENABLED",
|
|
483
|
+
"true",
|
|
484
|
+
"Bật/tắt toàn bộ lớp RabbitMQ. Đặt false thì phần còn lại của app chạy "
|
|
485
|
+
"bình thường, không cần gỡ thư viện hay sửa code.",
|
|
486
|
+
),
|
|
487
|
+
Bien(
|
|
488
|
+
"APP_RABBITMQ__URL",
|
|
489
|
+
"amqp://guest:guest@localhost:5672/",
|
|
490
|
+
"Dạng amqp://NGƯỜI_DÙNG:MẬT_KHẨU@HOST:CỔNG/VHOST. Đặt sai TÊN BIẾN thì "
|
|
491
|
+
"app dùng mặc định và báo lỗi localhost.",
|
|
492
|
+
bat_buoc=True,
|
|
493
|
+
),
|
|
494
|
+
"thời gian chờ",
|
|
495
|
+
Bien(
|
|
496
|
+
"APP_RABBITMQ__PUBLISH_TIMEOUT_SECONDS",
|
|
497
|
+
"5",
|
|
498
|
+
"Chờ broker xác nhận một lần đăng tin. Đè từng lời gọi bằng "
|
|
499
|
+
"publish(..., timeout=...).",
|
|
500
|
+
),
|
|
501
|
+
Bien("APP_RABBITMQ__CONNECT_TIMEOUT_SECONDS", "10", "Chờ tối đa khi mở kết nối."),
|
|
502
|
+
Bien(
|
|
503
|
+
"APP_RABBITMQ__HEARTBEAT_SECONDS",
|
|
504
|
+
"30",
|
|
505
|
+
"Nhịp tim AMQP. Mất mạng đột ngột không có gói FIN nào, đây là thứ duy "
|
|
506
|
+
"nhất phát hiện được; ngưỡng phát hiện khoảng 2 lần giá trị này.",
|
|
507
|
+
),
|
|
508
|
+
"tự nối lại (luôn bật, không tắt được)",
|
|
509
|
+
Bien("APP_RABBITMQ__RECONNECT_DELAY_SECONDS", "2", "Chờ trước lần thử nối lại đầu."),
|
|
510
|
+
Bien(
|
|
511
|
+
"APP_RABBITMQ__MAX_RECONNECT_DELAY_SECONDS",
|
|
512
|
+
"30",
|
|
513
|
+
"Trần thời gian chờ; mỗi lần hỏng lại tăng gấp đôi cho tới mức này.",
|
|
514
|
+
),
|
|
515
|
+
"",
|
|
516
|
+
(Bien.__doc__ and "") or "",
|
|
517
|
+
],
|
|
518
|
+
),
|
|
519
|
+
}
|
|
520
|
+
# Chính sách của từng consumer (thử lại, hàng đợi chết, prefetch...) KHÔNG nằm
|
|
521
|
+
# trong .env — khai ngay tại @rabbitmq_subscriber, xem docs/rabbitmq.md.
|
|
522
|
+
BLOCKS["rabbitmq"].items[:] = [i for i in BLOCKS["rabbitmq"].items if i != ""]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def strip_managed_block(text: str, section: str = "database") -> str:
|
|
526
|
+
end = end_marker(section)
|
|
527
|
+
begin = next(
|
|
528
|
+
(m for m in (begin_marker(section), _moc_cu(section)) if m in text),
|
|
529
|
+
begin_marker(section),
|
|
530
|
+
)
|
|
531
|
+
if begin not in text:
|
|
532
|
+
return text.rstrip("\n")
|
|
533
|
+
head, _, rest = text.partition(begin)
|
|
534
|
+
_, _, tail = rest.partition(end)
|
|
535
|
+
return (head.rstrip("\n") + "\n" + tail.lstrip("\n")).rstrip("\n")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def main(driver: str, env_path: Path) -> int:
|
|
539
|
+
khoi = BLOCKS.get(driver)
|
|
540
|
+
if khoi is None:
|
|
541
|
+
print(f"Thành phần không hợp lệ: {driver}. Chọn một trong {sorted(BLOCKS)}.")
|
|
542
|
+
return 1
|
|
543
|
+
|
|
544
|
+
begin, end = begin_marker(khoi.section), end_marker(khoi.section)
|
|
545
|
+
existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
|
|
546
|
+
had_block = begin in existing
|
|
547
|
+
body = strip_managed_block(existing, khoi.section)
|
|
548
|
+
|
|
549
|
+
# SQLite ghi thẳng ra file: thiếu thư mục là lần chạy đầu tiên chết ngay,
|
|
550
|
+
# với một lỗi nói về "unable to open database file" chứ không nói thiếu gì.
|
|
551
|
+
if driver == "sqlite":
|
|
552
|
+
(env_path.parent / "data").mkdir(parents=True, exist_ok=True)
|
|
553
|
+
|
|
554
|
+
noi_dung = render(khoi)
|
|
555
|
+
parts = [p for p in (body, begin, noi_dung, end) if p]
|
|
556
|
+
env_path.write_text("\n".join(parts) + "\n", encoding="utf-8")
|
|
557
|
+
|
|
558
|
+
print(f"{'Đã thay' if had_block else 'Đã thêm'} khối {khoi.section} trong {env_path}:")
|
|
559
|
+
for item in khoi.items:
|
|
560
|
+
if isinstance(item, Bien):
|
|
561
|
+
mac = _mac_dinh(khoi, item.key)
|
|
562
|
+
ghi_chu = f"BẮT BUỘC, đừng xoá — mặc định là {mac}" if item.bat_buoc else f"tuỳ chọn, mặc định {mac}"
|
|
563
|
+
print(f" {item.key}={item.value} ({ghi_chu})")
|
|
564
|
+
print("Sửa lại giá trị cho khớp máy bạn rồi chạy: pym dev")
|
|
565
|
+
return 0
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
if __name__ == "__main__":
|
|
569
|
+
raise SystemExit(main(sys.argv[1], Path(sys.argv[2] if len(sys.argv) > 2 else ".env")))
|
pymodular/cli/cong_cu.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""`pym test`, `pym lint`, `pym migrate`, `pym build`, `pym publish` — gọi hộ công cụ.
|
|
2
|
+
|
|
3
|
+
Ba lệnh này chỉ là lối tắt cho `pytest`, `ruff`, `alembic`. Giá trị của chúng
|
|
4
|
+
không nằm ở việc tiết kiệm chữ, mà ở chỗ khi thiếu công cụ thì nói thẳng phải
|
|
5
|
+
cài gì, thay vì ném ra `ModuleNotFoundError` trần.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from argparse import Namespace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_CAI_GI = {
|
|
18
|
+
"pytest": "pym install dev",
|
|
19
|
+
"ruff": "pym install dev",
|
|
20
|
+
"build": "pip install build",
|
|
21
|
+
"twine": "pip install twine",
|
|
22
|
+
"alembic": 'pip install "fastapi-modular[sqlite]" # hoặc [postgres]',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _co(ten: str) -> bool:
|
|
27
|
+
return importlib.util.find_spec(ten) is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _thieu(ten: str) -> int:
|
|
31
|
+
print(f"Chưa cài {ten}. Chạy: {_CAI_GI[ten]}")
|
|
32
|
+
return 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _goi(module: str, *args: str) -> int:
|
|
36
|
+
"""Chạy công cụ như một tiến trình con, giữ nguyên mã thoát của nó."""
|
|
37
|
+
return subprocess.call([sys.executable, "-m", module, *args])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def chay_cong_cu(args: Namespace) -> int:
|
|
41
|
+
if args.lenh == "test":
|
|
42
|
+
if not _co("pytest"):
|
|
43
|
+
return _thieu("pytest")
|
|
44
|
+
return _goi("pytest", "-q", *args.them)
|
|
45
|
+
|
|
46
|
+
if args.lenh == "lint":
|
|
47
|
+
if not _co("ruff"):
|
|
48
|
+
return _thieu("ruff")
|
|
49
|
+
co_dinh = ["check", *(["--fix"] if args.fix else [])]
|
|
50
|
+
return _goi("ruff", *co_dinh, *args.duong_dan)
|
|
51
|
+
|
|
52
|
+
if args.lenh == "build":
|
|
53
|
+
return _build(args)
|
|
54
|
+
|
|
55
|
+
if args.lenh == "publish":
|
|
56
|
+
return _publish(args)
|
|
57
|
+
|
|
58
|
+
return _migrate(args)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _build(args: Namespace) -> int:
|
|
62
|
+
if not _co("build"):
|
|
63
|
+
return _thieu("build")
|
|
64
|
+
if not Path("pyproject.toml").exists():
|
|
65
|
+
print("Không thấy pyproject.toml ở thư mục này — không có gì để dựng.")
|
|
66
|
+
return 1
|
|
67
|
+
if not args.no_clean:
|
|
68
|
+
# dist/ cũ còn sót lại là cách phổ biến nhất để đẩy nhầm một bản cũ lên
|
|
69
|
+
# PyPI: twine upload dist/* lấy TẤT CẢ file trong đó.
|
|
70
|
+
shutil.rmtree("dist", ignore_errors=True)
|
|
71
|
+
return _goi("build")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _publish(args: Namespace) -> int:
|
|
75
|
+
if not _co("twine"):
|
|
76
|
+
return _thieu("twine")
|
|
77
|
+
goi = sorted(Path("dist").glob("*")) if Path("dist").exists() else []
|
|
78
|
+
if not goi:
|
|
79
|
+
print("dist/ trống. Chạy `pym build` trước.")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
print("Sắp đẩy lên", "TestPyPI" if args.test else "PyPI", "các file:")
|
|
83
|
+
for f in goi:
|
|
84
|
+
print(f" {f.name}")
|
|
85
|
+
kho = ["--repository", "testpypi"] if args.test else []
|
|
86
|
+
return _goi("twine", "upload", *kho, *[str(f) for f in goi])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _migrate(args: Namespace) -> int:
|
|
90
|
+
if not _co("alembic"):
|
|
91
|
+
return _thieu("alembic")
|
|
92
|
+
if not Path("alembic.ini").exists():
|
|
93
|
+
print(
|
|
94
|
+
"Không thấy alembic.ini ở thư mục này.\n"
|
|
95
|
+
"Migration cần một lần dựng: `alembic init migrations` rồi trỏ env.py vào\n"
|
|
96
|
+
"cấu hình của bạn — xem docs/migrations.md."
|
|
97
|
+
)
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
if args.viec == "create":
|
|
101
|
+
if not args.message:
|
|
102
|
+
print('Thiếu mô tả. Dùng: pym migrate create -m "them cot phone"')
|
|
103
|
+
return 1
|
|
104
|
+
return _goi("alembic", "revision", "--autogenerate", "-m", args.message)
|
|
105
|
+
|
|
106
|
+
return _goi("alembic", *{
|
|
107
|
+
"up": ("upgrade", "head"),
|
|
108
|
+
"down": ("downgrade", "-1"),
|
|
109
|
+
"history": ("history", "--indicate-current"),
|
|
110
|
+
"sql": ("upgrade", "head", "--sql"),
|
|
111
|
+
}[args.viec])
|