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
pymodular/core/config.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
11
|
+
|
|
12
|
+
Environment = Literal["local", "dev", "staging", "prod"]
|
|
13
|
+
|
|
14
|
+
class LogSettings(BaseModel):
|
|
15
|
+
level: str = "INFO"
|
|
16
|
+
json_format: bool = False
|
|
17
|
+
"""Bật để log dạng JSON (production); tắt để log màu, dễ đọc (local)."""
|
|
18
|
+
|
|
19
|
+
class CorsSettings(BaseModel):
|
|
20
|
+
"""Mặc định mở cho local. Prod bắt buộc liệt kê origin cụ thể — xem
|
|
21
|
+
`Settings.check_production_safety()`; `["*"]` cộng allow_credentials=True
|
|
22
|
+
khiến Starlette phản chiếu mọi Origin, tức bất kỳ website nào cũng gửi
|
|
23
|
+
được request kèm cookie của người dùng."""
|
|
24
|
+
|
|
25
|
+
allow_origins: list[str] = Field(default_factory=lambda: ["*"])
|
|
26
|
+
allow_methods: list[str] = Field(default_factory=lambda: ["*"])
|
|
27
|
+
allow_headers: list[str] = Field(default_factory=lambda: ["*"])
|
|
28
|
+
allow_credentials: bool = True
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def allows_any_origin(self) -> bool:
|
|
32
|
+
return "*" in self.allow_origins
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Driver = Literal["memory", "sqlite", "postgres", "mongodb"]
|
|
36
|
+
SchemaMode = Literal["off", "create", "sync"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DatabaseSettings(BaseModel):
|
|
40
|
+
"""Cấu hình database. Chỉ MỘT driver được dùng tại một thời điểm.
|
|
41
|
+
|
|
42
|
+
Đặt qua biến môi trường, ví dụ:
|
|
43
|
+
APP_DB__DRIVER=postgres
|
|
44
|
+
APP_DB__DSN=postgresql+asyncpg://user:pass@localhost:5432/app
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
driver: Driver = "memory"
|
|
48
|
+
dsn: str | None = None
|
|
49
|
+
name: str = "app"
|
|
50
|
+
echo: bool = False
|
|
51
|
+
|
|
52
|
+
schema_mode: SchemaMode = "create"
|
|
53
|
+
"""Mức độ tự chỉnh schema lúc khởi động (tương đương `synchronize` của TypeORM).
|
|
54
|
+
|
|
55
|
+
- "off" : không đụng gì. Dùng cho production, đi kèm Alembic.
|
|
56
|
+
- "create" : chỉ tạo bảng còn thiếu. Thêm/xoá trường trong entity KHÔNG có
|
|
57
|
+
tác dụng lên bảng đã tồn tại.
|
|
58
|
+
- "sync" : thêm cột mới, và báo cột thừa / cột lệch kiểu. Chỉ dùng ở dev.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
drop_columns: bool = False
|
|
62
|
+
"""Cho phép schema_mode="sync" XOÁ cột không còn trong entity.
|
|
63
|
+
|
|
64
|
+
Mặc định tắt vì xoá cột là mất dữ liệu vĩnh viễn. Bật khi bạn thật sự muốn
|
|
65
|
+
dọn cột thừa trong lúc phát triển.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
# ---- kết nối & phục hồi khi database rớt --------------------------------
|
|
69
|
+
pool_pre_ping: bool = True
|
|
70
|
+
"""Kiểm tra connection còn sống trước khi giao cho request (chỉ SQL).
|
|
71
|
+
|
|
72
|
+
Không bật thì sau mỗi lần database restart hoặc firewall cắt kết nối nhàn
|
|
73
|
+
rỗi, request đầu tiên sẽ lỗi 500 vì nhận phải connection đã chết. Bật thì
|
|
74
|
+
pool âm thầm thay bằng connection mới, client không thấy gì.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
pool_size: int = 5
|
|
78
|
+
max_overflow: int = 10
|
|
79
|
+
"""Trần connection là (pool_size + max_overflow) x SỐ WORKER, không phải
|
|
80
|
+
tổng của cả app. Mặc định 15 mỗi worker; Postgres mặc định max_connections
|
|
81
|
+
là 100, nên từ 6 worker trở lên phải giảm số này hoặc nâng max_connections."""
|
|
82
|
+
pool_recycle_seconds: int = 1800
|
|
83
|
+
"""Đóng và mở lại connection cũ hơn ngưỡng này. Nhiều proxy/firewall cắt
|
|
84
|
+
kết nối nhàn rỗi sau 30–60 phút mà không báo cho hai đầu."""
|
|
85
|
+
|
|
86
|
+
connect_timeout_seconds: float = 10.0
|
|
87
|
+
"""Chờ tối đa bấy nhiêu giây khi MỞ kết nối. Với MongoDB đây cũng là hạn
|
|
88
|
+
chọn server — để mặc định 30s của driver thì request sẽ treo rất lâu."""
|
|
89
|
+
|
|
90
|
+
query_timeout_seconds: float = 15.0
|
|
91
|
+
"""Chờ tối đa bấy nhiêu giây cho MỘT câu truy vấn đã gửi đi.
|
|
92
|
+
|
|
93
|
+
Khác connect_timeout: nếu database treo giữa lúc đang chạy câu lệnh (mất
|
|
94
|
+
điện, đóng băng, khoá bảng), connection vẫn "mở" nên connect_timeout không
|
|
95
|
+
cứu được — request sẽ treo cho tới khi client bỏ cuộc."""
|
|
96
|
+
|
|
97
|
+
startup_retries: int = 10
|
|
98
|
+
startup_retry_delay_seconds: float = 1.0
|
|
99
|
+
|
|
100
|
+
circuit_breaker: bool = True
|
|
101
|
+
"""Ngắt mạch khi database hỏng liên tiếp, thay vì để mọi request cùng chờ
|
|
102
|
+
hết timeout. Không áp dụng cho driver 'memory'."""
|
|
103
|
+
|
|
104
|
+
circuit_failure_threshold: int = 5
|
|
105
|
+
circuit_reset_seconds: float = 10.0
|
|
106
|
+
"""Thử lại khi khởi động mà database chưa sẵn sàng (hay gặp với
|
|
107
|
+
docker compose: app lên trước database)."""
|
|
108
|
+
|
|
109
|
+
_DEFAULT_DSN: ClassVar[dict[str, str]] = {
|
|
110
|
+
"sqlite": "sqlite+aiosqlite:///./data/{name}.db",
|
|
111
|
+
"postgres": "postgresql+asyncpg://postgres:postgres@localhost:5432/{name}",
|
|
112
|
+
"mongodb": "mongodb://localhost:27017",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def resolved_dsn(self) -> str:
|
|
117
|
+
if self.dsn:
|
|
118
|
+
return self.dsn
|
|
119
|
+
template = self._DEFAULT_DSN.get(self.driver, "")
|
|
120
|
+
return template.format(name=self.name)
|
|
121
|
+
|
|
122
|
+
class WebSocketSettings(BaseModel):
|
|
123
|
+
"""Cấu hình lớp WebSocket. Đặt qua APP_WS__*, ví dụ APP_WS__ADAPTER=redis."""
|
|
124
|
+
|
|
125
|
+
send_queue_size: int = 100
|
|
126
|
+
"""Trần số tin CHỜ GỬI cho mỗi kết nối.
|
|
127
|
+
|
|
128
|
+
Client đọc chậm mà server cứ đẩy thì hàng đợi phình cho tới khi hết RAM —
|
|
129
|
+
một client hỏng kéo sập cả worker. Có trần thì hỏng cục bộ ở đúng client đó.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
overflow: Literal["close", "drop_oldest"] = "close"
|
|
133
|
+
"""Xử lý khi hàng đợi đầy.
|
|
134
|
+
|
|
135
|
+
- "close" : ngắt kết nối (mã 1013). Client nối lại và tải lại trạng
|
|
136
|
+
thái — trung thực hơn là âm thầm mất tin.
|
|
137
|
+
- "drop_oldest": bỏ tin cũ nhất. Hợp với dữ liệu chỉ cần bản mới nhất
|
|
138
|
+
(vị trí, nhiệt độ, tiến độ).
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
heartbeat_seconds: float = 25.0
|
|
142
|
+
"""Chu kỳ server đẩy `ping`. Dưới 30s vì nhiều proxy (nginx, ALB, Cloudflare)
|
|
143
|
+
cắt kết nối WebSocket nhàn rỗi khoảng 60s mà không báo hai đầu."""
|
|
144
|
+
|
|
145
|
+
idle_timeout_seconds: float = 70.0
|
|
146
|
+
"""Không nhận được KHUNG TIN NÀO trong bấy nhiêu giây thì đóng.
|
|
147
|
+
|
|
148
|
+
Phải lớn hơn heartbeat vài lần: client trả lời `pong` sẽ làm mới đồng hồ
|
|
149
|
+
này, nên chỉ kết nối thật sự chết mới bị cắt. Thiếu nó thì socket "ma"
|
|
150
|
+
(mất mạng đột ngột, TCP chưa kịp biết) tích lại tới hết bộ nhớ."""
|
|
151
|
+
|
|
152
|
+
max_message_bytes: int = 64 * 1024
|
|
153
|
+
max_messages_per_second: float = 50.0
|
|
154
|
+
"""Trần tần suất mỗi kết nối; 0 để tắt."""
|
|
155
|
+
|
|
156
|
+
burst_messages: int = 100
|
|
157
|
+
"""Số tin cho phép bùng tức thời trước khi bị siết theo tần suất trên."""
|
|
158
|
+
|
|
159
|
+
max_connections: int = 5000
|
|
160
|
+
"""Trần kết nối MỖI WORKER."""
|
|
161
|
+
|
|
162
|
+
max_connections_per_user: int = 10
|
|
163
|
+
"""Trần kết nối đồng thời của một tài khoản (nhiều tab, nhiều thiết bị);
|
|
164
|
+
0 để tắt. Chỉ có tác dụng khi gateway có guard xác thực."""
|
|
165
|
+
|
|
166
|
+
max_rooms_per_socket: int = 64
|
|
167
|
+
|
|
168
|
+
adapter: Literal["local", "redis"] = "local"
|
|
169
|
+
"""`local` chỉ đúng khi chạy MỘT worker: sổ kết nối nằm trong RAM tiến
|
|
170
|
+
trình, worker này không thấy kết nối của worker kia nên broadcast sẽ thiếu
|
|
171
|
+
người nhận. Nhiều worker/nhiều máy thì phải dùng `redis`."""
|
|
172
|
+
|
|
173
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
174
|
+
channel: str = "ws:broadcast"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class RabbitSettings(BaseModel):
|
|
178
|
+
"""Cấu hình RabbitMQ. Đặt qua APP_RABBITMQ__*, ví dụ APP_RABBITMQ__ENABLED=true.
|
|
179
|
+
|
|
180
|
+
MẶC ĐỊNH TẮT. Dự án không dùng RabbitMQ thì không phải cài `aio-pika`,
|
|
181
|
+
không phải đụng gì tới file này, và mọi thứ chạy y như cũ — giống hệt cách
|
|
182
|
+
driver database được tách riêng.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
enabled: bool = False
|
|
186
|
+
url: str = "amqp://guest:guest@localhost:5672/"
|
|
187
|
+
"""Mặc định trỏ localhost. Bật RabbitMQ mà quên đặt biến này thì lúc khởi
|
|
188
|
+
động sẽ có cảnh báo `rabbitmq.default_url` — đọc kỹ nó trước khi đi tìm
|
|
189
|
+
xem broker có chết không."""
|
|
190
|
+
|
|
191
|
+
publish_timeout_seconds: float = 5.0
|
|
192
|
+
"""Chờ broker xác nhận đã nhận tin. Đây là thuộc tính của KẾT NỐI (mạng
|
|
193
|
+
chậm, broker quá tải), không phải quyết định nghiệp vụ — nên nằm ở đây.
|
|
194
|
+
Lời gọi nào cần khác thì truyền `publish(..., timeout=...)`."""
|
|
195
|
+
connect_timeout_seconds: float = 10.0
|
|
196
|
+
|
|
197
|
+
# ---- tự nối lại ---------------------------------------------------------
|
|
198
|
+
heartbeat_seconds: int = 30
|
|
199
|
+
"""Nhịp tim AMQP. Mất điện đột ngột hay rút cáp thì KHÔNG có gói FIN nào
|
|
200
|
+
cả — hai đầu vẫn tưởng kết nối còn sống. Nhịp tim là thứ duy nhất phát hiện
|
|
201
|
+
được: quá hai nhịp không thấy hồi âm thì client coi như đứt và nối lại.
|
|
202
|
+
Ngưỡng phát hiện vì vậy là khoảng 2 x giá trị này. Hạ xuống thì phát hiện
|
|
203
|
+
nhanh hơn nhưng dễ báo đứt oan khi mạng chớp."""
|
|
204
|
+
|
|
205
|
+
reconnect_delay_seconds: float = 2.0
|
|
206
|
+
"""Chờ bao lâu trước lần thử nối lại đầu tiên. Các lần sau tăng gấp đôi cho
|
|
207
|
+
tới `max_reconnect_delay_seconds`, để broker vừa hồi sinh không bị hàng
|
|
208
|
+
trăm tiến trình đập vào cùng lúc."""
|
|
209
|
+
|
|
210
|
+
max_reconnect_delay_seconds: float = 30.0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def dead_letter_exchange(self) -> str:
|
|
215
|
+
return "dlx"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class RedisSettings(BaseModel):
|
|
219
|
+
"""Cấu hình Redis. Đặt qua APP_REDIS__*, ví dụ APP_REDIS__ENABLED=true.
|
|
220
|
+
|
|
221
|
+
MẶC ĐỊNH TẮT, và độc lập với `APP_WS__REDIS_URL`. Hai thứ đó cố ý tách
|
|
222
|
+
nhau: adapter WebSocket phải chạy được ở dự án không hề bật lớp Redis này,
|
|
223
|
+
và ngược lại. Trỏ cả hai vào cùng một server thì hoàn toàn bình thường.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
enabled: bool = False
|
|
227
|
+
url: str = "redis://localhost:6379/0"
|
|
228
|
+
"""Dạng redis://[:MẬT_KHẨU@]HOST:CỔNG/SỐ_DB, hoặc rediss:// nếu có TLS."""
|
|
229
|
+
|
|
230
|
+
key_prefix: str = ""
|
|
231
|
+
"""Tiền tố ghép vào MỌI khoá. Nhiều ứng dụng dùng chung một Redis thì đặt
|
|
232
|
+
khác nhau ("don-hang:", "chat:") để không ai ghi đè khoá của ai."""
|
|
233
|
+
|
|
234
|
+
connect_timeout_seconds: float = 5.0
|
|
235
|
+
command_timeout_seconds: float = 5.0
|
|
236
|
+
"""Trần thời gian cho MỘT lệnh. Redis chậm còn tệ hơn Redis chết: không có
|
|
237
|
+
trần thì mọi request đang chờ cache sẽ treo theo."""
|
|
238
|
+
|
|
239
|
+
reconnect_delay_seconds: float = 1.0
|
|
240
|
+
max_reconnect_delay_seconds: float = 30.0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class MqttSettings(BaseModel):
|
|
244
|
+
"""Cấu hình MQTT. Đặt qua APP_MQTT__*, ví dụ APP_MQTT__ENABLED=true.
|
|
245
|
+
|
|
246
|
+
MẶC ĐỊNH TẮT. Dùng cho thiết bị IoT: nhẹ, giữ kết nối lâu, chịu được mạng
|
|
247
|
+
chập chờn.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
enabled: bool = False
|
|
251
|
+
url: str = "mqtt://localhost:1883"
|
|
252
|
+
"""Dạng mqtt://[NGƯỜI_DÙNG:MẬT_KHẨU@]HOST:CỔNG, hoặc mqtts:// nếu có TLS."""
|
|
253
|
+
|
|
254
|
+
client_id: str = ""
|
|
255
|
+
"""Danh tính phiên trên broker. Để trống thì sinh ngẫu nhiên mỗi lần chạy —
|
|
256
|
+
tiện cho dev, nhưng KHÔNG dùng được với clean_session=false vì mỗi lần khởi
|
|
257
|
+
động sẽ là một phiên mới toanh. Chạy nhiều worker thì mỗi worker phải một
|
|
258
|
+
id khác nhau, trùng id là hai bên đá nhau ra khỏi broker liên tục."""
|
|
259
|
+
|
|
260
|
+
clean_session: bool = True
|
|
261
|
+
"""false = broker GIỮ tin QoS>=1 lại trong lúc client ngắt, nối lại là giao
|
|
262
|
+
tiếp. Cần đi kèm client_id cố định. true = mất tin trong lúc ngắt."""
|
|
263
|
+
|
|
264
|
+
keepalive_seconds: int = 30
|
|
265
|
+
"""Nhịp tim MQTT: quá 1.5 nhịp không thấy gì thì broker coi như client
|
|
266
|
+
chết. Đây là thứ duy nhất phát hiện được cảnh rút cáp/mất điện."""
|
|
267
|
+
|
|
268
|
+
connect_timeout_seconds: float = 10.0
|
|
269
|
+
reconnect_delay_seconds: float = 1.0
|
|
270
|
+
max_reconnect_delay_seconds: float = 30.0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class KafkaSettings(BaseModel):
|
|
274
|
+
"""Cấu hình Kafka. Đặt qua APP_KAFKA__*, ví dụ APP_KAFKA__ENABLED=true.
|
|
275
|
+
|
|
276
|
+
MẶC ĐỊNH TẮT. Dùng khi cần nhật ký sự kiện ĐỌC LẠI ĐƯỢC: tin không biến mất
|
|
277
|
+
sau khi xử lý mà nằm lại theo thời gian giữ, nhiều nhóm consumer đọc cùng
|
|
278
|
+
một dòng tin ở các vị trí khác nhau.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
enabled: bool = False
|
|
282
|
+
bootstrap_servers: str = "localhost:9092"
|
|
283
|
+
"""Danh sách HOST:CỔNG ngăn bằng dấu phẩy. Chỉ cần vài broker để hỏi đường;
|
|
284
|
+
client tự tìm ra phần còn lại của cụm."""
|
|
285
|
+
|
|
286
|
+
client_id: str = "pymodular"
|
|
287
|
+
acks: Literal["0", "1", "all"] = "all"
|
|
288
|
+
"""Bao nhiêu bản sao phải ghi xong thì mới coi là gửi thành công.
|
|
289
|
+
all = an toàn nhất (chậm hơn) | 1 = chỉ leader | 0 = bắn đi rồi thôi."""
|
|
290
|
+
|
|
291
|
+
request_timeout_seconds: float = 20.0
|
|
292
|
+
connect_timeout_seconds: float = 10.0
|
|
293
|
+
reconnect_delay_seconds: float = 1.0
|
|
294
|
+
max_reconnect_delay_seconds: float = 30.0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Settings(BaseSettings):
|
|
298
|
+
"""Cấu hình của khung. **Kế thừa được** để thêm biến của riêng bạn.
|
|
299
|
+
|
|
300
|
+
# src/config.py
|
|
301
|
+
from pydantic import BaseModel, Field
|
|
302
|
+
from pymodular import Settings
|
|
303
|
+
|
|
304
|
+
class JwtSettings(BaseModel):
|
|
305
|
+
secret: str = ""
|
|
306
|
+
ttl_seconds: int = 3600
|
|
307
|
+
|
|
308
|
+
class AppSettings(Settings):
|
|
309
|
+
jwt: JwtSettings = Field(default_factory=JwtSettings, alias="APP_JWT")
|
|
310
|
+
stripe_key: str = Field(default="", alias="APP_STRIPE_KEY")
|
|
311
|
+
|
|
312
|
+
# src/main.py
|
|
313
|
+
app = create_app(AppSettings())
|
|
314
|
+
|
|
315
|
+
Đọc được ngay từ `.env` hoặc biến môi trường: `APP_JWT__SECRET=...`,
|
|
316
|
+
`APP_STRIPE_KEY=...`. Không phải khai báo ở đâu khác.
|
|
317
|
+
|
|
318
|
+
Service nhận nó qua DI bằng CHÍNH lớp con, và vẫn có gợi ý kiểu đầy đủ:
|
|
319
|
+
|
|
320
|
+
@injectable
|
|
321
|
+
class TokenService:
|
|
322
|
+
def __init__(self, settings: AppSettings) -> None:
|
|
323
|
+
self._secret = settings.jwt.secret
|
|
324
|
+
|
|
325
|
+
`create_app()` đăng ký instance dưới cả `Settings` lẫn mọi lớp con của nó,
|
|
326
|
+
nên thư viện hỏi `Settings` và code của bạn hỏi `AppSettings` đều nhận đúng
|
|
327
|
+
một đối tượng đó.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
model_config = SettingsConfigDict(
|
|
331
|
+
env_file=".env",
|
|
332
|
+
env_file_encoding="utf-8",
|
|
333
|
+
env_nested_delimiter="__",
|
|
334
|
+
env_prefix="",
|
|
335
|
+
extra="ignore",
|
|
336
|
+
case_sensitive=False,
|
|
337
|
+
populate_by_name=True,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Các trường cấp app dùng prefix APP_ để tránh đụng biến môi trường hệ thống.
|
|
341
|
+
env: Environment = Field(default="local", alias="APP_ENV")
|
|
342
|
+
debug: bool = Field(default=True, alias="APP_DEBUG")
|
|
343
|
+
name: str = Field(default="pymodular", alias="APP_NAME")
|
|
344
|
+
version: str = Field(default="0.1.0", alias="APP_VERSION")
|
|
345
|
+
api_prefix: str = Field(default="/api", alias="APP_API_PREFIX")
|
|
346
|
+
host: str = Field(default="0.0.0.0", alias="APP_HOST")
|
|
347
|
+
port: int = Field(default=8000, alias="APP_PORT")
|
|
348
|
+
|
|
349
|
+
# Nhóm lồng nhau cũng dùng tiền tố APP_ cho đồng bộ; đặt qua biến môi
|
|
350
|
+
# trường bằng dấu ngăn "__", ví dụ APP_DB__DRIVER=postgres.
|
|
351
|
+
log: LogSettings = Field(default_factory=LogSettings, alias="APP_LOG")
|
|
352
|
+
cors: CorsSettings = Field(default_factory=CorsSettings, alias="APP_CORS")
|
|
353
|
+
db: DatabaseSettings = Field(default_factory=DatabaseSettings, alias="APP_DB")
|
|
354
|
+
ws: WebSocketSettings = Field(default_factory=WebSocketSettings, alias="APP_WS")
|
|
355
|
+
rabbitmq: RabbitSettings = Field(default_factory=RabbitSettings, alias="APP_RABBITMQ")
|
|
356
|
+
redis: RedisSettings = Field(default_factory=RedisSettings, alias="APP_REDIS")
|
|
357
|
+
mqtt: MqttSettings = Field(default_factory=MqttSettings, alias="APP_MQTT")
|
|
358
|
+
kafka: KafkaSettings = Field(default_factory=KafkaSettings, alias="APP_KAFKA")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def is_prod(self) -> bool:
|
|
363
|
+
return self.env == "prod"
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def docs_url(self) -> str | None:
|
|
367
|
+
return None if self.is_prod else "/docs"
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def redoc_url(self) -> str | None:
|
|
371
|
+
return None if self.is_prod else "/redoc"
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def openapi_url(self) -> str | None:
|
|
375
|
+
return None if self.is_prod else f"{self.api_prefix}/openapi.json"
|
|
376
|
+
|
|
377
|
+
def check_production_safety(self) -> list[str]:
|
|
378
|
+
"""Những cấu hình không được phép mang lên prod. Trả về danh sách cảnh báo."""
|
|
379
|
+
problems: list[str] = []
|
|
380
|
+
if not self.is_prod:
|
|
381
|
+
return problems
|
|
382
|
+
|
|
383
|
+
if self.cors.allows_any_origin:
|
|
384
|
+
problems.append(
|
|
385
|
+
"cors.allow_origins đang là '*' — với allow_credentials=True thì mọi "
|
|
386
|
+
"website đều gọi được API kèm cookie người dùng. Hãy liệt kê domain cụ thể."
|
|
387
|
+
)
|
|
388
|
+
if self.debug:
|
|
389
|
+
problems.append("debug=True ở prod sẽ lộ chi tiết lỗi ra client.")
|
|
390
|
+
if self.db.driver == "memory":
|
|
391
|
+
problems.append(
|
|
392
|
+
"db.driver='memory' mất dữ liệu khi restart và sai khi chạy nhiều worker."
|
|
393
|
+
)
|
|
394
|
+
if self.db.schema_mode != "off":
|
|
395
|
+
problems.append(
|
|
396
|
+
f"db.schema_mode={self.db.schema_mode!r} tự chỉnh schema lúc khởi động; "
|
|
397
|
+
"prod nên đặt 'off' và dùng Alembic."
|
|
398
|
+
)
|
|
399
|
+
if self.db.drop_columns:
|
|
400
|
+
problems.append("db.drop_columns=True có thể xoá cột kèm dữ liệu.")
|
|
401
|
+
if self.ws.adapter == "local":
|
|
402
|
+
problems.append(
|
|
403
|
+
"ws.adapter='local' chỉ đúng với MỘT worker: mỗi worker giữ sổ kết nối "
|
|
404
|
+
"riêng nên broadcast sẽ không tới được client đang nối vào worker khác. "
|
|
405
|
+
"Chạy nhiều worker thì đặt APP_WS__ADAPTER=redis."
|
|
406
|
+
)
|
|
407
|
+
return problems
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# Biến môi trường đã đổi tên. Đặt tên cũ thì pydantic lặng lẽ bỏ qua và dùng
|
|
411
|
+
# giá trị mặc định — nghĩa là app chạy sai mà không báo gì. Bảng này để phát
|
|
412
|
+
# hiện và nói thẳng phải sửa thành gì.
|
|
413
|
+
_TEN_CU: dict[str, str] = {
|
|
414
|
+
"APP_MQ__": "APP_RABBITMQ__",
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# Biến đã bỏ hẳn. Để trong .env thì vô hại nhưng gây hiểu nhầm là nó còn tác
|
|
418
|
+
# dụng — nói rõ để người ta xoá đi.
|
|
419
|
+
_DA_BO: frozenset[str] = frozenset(
|
|
420
|
+
{
|
|
421
|
+
"BRIDGE_ENABLED", # cầu nối RabbitMQ -> WebSocket, đã gỡ
|
|
422
|
+
"MAX_SUBSCRIPTIONS_PER_SOCKET", # nt
|
|
423
|
+
"FAIL_ON_STARTUP", # luôn tự nối lại, không còn lựa chọn dừng app
|
|
424
|
+
"EXCHANGES", # exchange tự khai lúc dùng, không cần liệt kê
|
|
425
|
+
"DURABLE", # chuyển vào @rabbitmq_subscriber(durable=...)
|
|
426
|
+
"CONSUMER_MAX_RETRIES", # chuyển vào @rabbitmq_subscriber(max_retries=...)
|
|
427
|
+
"CONSUMER_RETRY_DELAY_SECONDS", # chuyển vào @rabbitmq_subscriber(retry_delay=...)
|
|
428
|
+
"PREFETCH", # chuyển vào @rabbitmq_subscriber(prefetch=...)
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def check_deprecated_env(env_file: str = ".env") -> list[str]:
|
|
434
|
+
"""Tìm khai báo mang tên cũ, trong BIẾN MÔI TRƯỜNG lẫn trong .env.
|
|
435
|
+
|
|
436
|
+
Phải soi cả file .env chứ không chỉ os.environ: pydantic đọc thẳng file đó,
|
|
437
|
+
còn tên cũ nằm trong file thì không xuất hiện ở os.environ. Bỏ sót chỗ này
|
|
438
|
+
thì cảnh báo vô dụng đúng vào trường hợp cần nó nhất.
|
|
439
|
+
"""
|
|
440
|
+
names = set(os.environ)
|
|
441
|
+
|
|
442
|
+
path = Path(env_file)
|
|
443
|
+
if path.exists():
|
|
444
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
445
|
+
line = line.strip()
|
|
446
|
+
if line and not line.startswith("#") and "=" in line:
|
|
447
|
+
names.add(line.split("=", 1)[0].strip())
|
|
448
|
+
|
|
449
|
+
problems: list[str] = []
|
|
450
|
+
for name in sorted(names):
|
|
451
|
+
# Kiểm tra "đã bỏ" TRƯỚC "đổi tên": một biến vừa mang tên cũ vừa không
|
|
452
|
+
# còn dùng thì lời khuyên đúng là xoá đi, không phải đổi tên.
|
|
453
|
+
if name.rpartition("__")[2] in _DA_BO and name.startswith(("APP_MQ__", "APP_RABBITMQ__")):
|
|
454
|
+
problems.append(f"{name} -> không còn dùng, xoá dòng này")
|
|
455
|
+
continue
|
|
456
|
+
for cu, moi in _TEN_CU.items():
|
|
457
|
+
if name.startswith(cu):
|
|
458
|
+
problems.append(f"{name} -> đổi thành {name.replace(cu, moi, 1)}")
|
|
459
|
+
return problems
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
_LOP_SETTINGS: type[Settings] = Settings
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def use_settings(cls: type[Settings]) -> None:
|
|
466
|
+
"""Khai lớp Settings mở rộng, cho những chỗ khung TỰ dựng cấu hình.
|
|
467
|
+
|
|
468
|
+
`create_app(AppSettings())` đã tự gọi hàm này. Chỉ phải gọi tay ở những
|
|
469
|
+
đường vào không đi qua create_app — chủ yếu là Alembic (`migrations/env.py`)
|
|
470
|
+
và script chạy một lần:
|
|
471
|
+
|
|
472
|
+
from src.core.config import AppSettings
|
|
473
|
+
use_settings(AppSettings)
|
|
474
|
+
settings = get_settings() # giờ trả về AppSettings
|
|
475
|
+
"""
|
|
476
|
+
global _LOP_SETTINGS
|
|
477
|
+
if not (isinstance(cls, type) and issubclass(cls, Settings)):
|
|
478
|
+
raise TypeError(f"{cls!r} phải là lớp con của Settings")
|
|
479
|
+
if cls is not _LOP_SETTINGS:
|
|
480
|
+
_LOP_SETTINGS = cls
|
|
481
|
+
get_settings.cache_clear()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def settings_class() -> type[Settings]:
|
|
485
|
+
"""Lớp đang được dùng để dựng cấu hình."""
|
|
486
|
+
return _LOP_SETTINGS
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@lru_cache(maxsize=1)
|
|
490
|
+
def get_settings() -> Settings:
|
|
491
|
+
"""Singleton settings — cache để không parse .env nhiều lần.
|
|
492
|
+
|
|
493
|
+
Trả về lớp đã khai bằng `use_settings()`, mặc định là `Settings`.
|
|
494
|
+
"""
|
|
495
|
+
return _LOP_SETTINGS()
|