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,471 @@
|
|
|
1
|
+
"""Dựng một dự án chạy được ngay.
|
|
2
|
+
|
|
3
|
+
pym new blog tạo thư mục blog/ rồi đổ file vào đó
|
|
4
|
+
pym init đổ file vào THƯ MỤC HIỆN TẠI
|
|
5
|
+
|
|
6
|
+
Bộ khung sinh ra:
|
|
7
|
+
|
|
8
|
+
src/main.py điểm vào — lắp ráp app, file của bạn, sửa thoải mái
|
|
9
|
+
src/core/config.py cấu hình: kế thừa Settings để thêm biến .env của bạn
|
|
10
|
+
src/core/lifespan.py việc lúc khởi động / lúc tắt của riêng ứng dụng
|
|
11
|
+
src/api/health/ một module mẫu
|
|
12
|
+
|
|
13
|
+
Mọi thứ khác (module nghiệp vụ, gateway, consumer) sinh sau bằng `pym module`.
|
|
14
|
+
|
|
15
|
+
`init` không bao giờ GHI ĐÈ: file nào đã có thì bỏ qua và báo lại. Nhờ vậy chạy
|
|
16
|
+
nó trong một thư mục đang có sẵn code là an toàn, và chạy lại lần hai chỉ bù
|
|
17
|
+
những file còn thiếu.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import unicodedata
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
TEN_HOP_LE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
27
|
+
|
|
28
|
+
REPO = "https://github.com/quanglinh2909/pymodular"
|
|
29
|
+
|
|
30
|
+
GOC = '''"""Ứng dụng — code của bạn.
|
|
31
|
+
|
|
32
|
+
src/main.py điểm vào: lắp ráp app, sửa thoải mái
|
|
33
|
+
src/core/ thứ dùng chung của ứng dụng (config, helper, guard riêng)
|
|
34
|
+
src/api/ các module nghiệp vụ; mỗi thư mục con là một module
|
|
35
|
+
"""
|
|
36
|
+
'''
|
|
37
|
+
|
|
38
|
+
LIFESPAN = '''"""Vòng đời ứng dụng — FILE CỦA BẠN.
|
|
39
|
+
|
|
40
|
+
Khung lo phần hạ tầng: mở/đóng database, WebSocket, và những lớp hàng đợi đang
|
|
41
|
+
bật (RabbitMQ, Redis, MQTT, Kafka). Việc RIÊNG của ứng dụng — nạp cache, hâm
|
|
42
|
+
nóng model, đăng ký với service discovery, đóng sổ khi tắt — viết ở đây.
|
|
43
|
+
|
|
44
|
+
Thứ tự quan trọng và cố ý:
|
|
45
|
+
|
|
46
|
+
khung mở database, hàng đợi
|
|
47
|
+
-> việc khởi động của bạn (đã có database để dùng)
|
|
48
|
+
-> app phục vụ request
|
|
49
|
+
-> việc lúc tắt của bạn (database VẪN CÒN để ghi nốt)
|
|
50
|
+
khung đóng hàng đợi, database
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
from collections.abc import AsyncIterator
|
|
56
|
+
from contextlib import asynccontextmanager
|
|
57
|
+
|
|
58
|
+
from fastapi import FastAPI
|
|
59
|
+
from pymodular import get_logger
|
|
60
|
+
from pymodular import lifespan as framework_lifespan
|
|
61
|
+
|
|
62
|
+
log = get_logger(__name__)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@asynccontextmanager
|
|
66
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
67
|
+
async with framework_lifespan(app):
|
|
68
|
+
# --- KHỞI ĐỘNG: chạy sau khi database và hàng đợi đã sẵn sàng ---
|
|
69
|
+
log.info("app.ready")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
# --- TẮT: chạy trước khi khung đóng database ---
|
|
75
|
+
log.info("app.closing")
|
|
76
|
+
'''
|
|
77
|
+
|
|
78
|
+
MAIN = '''"""Điểm vào — chạy bằng `pym dev`.
|
|
79
|
+
|
|
80
|
+
FILE NÀY LÀ CỦA BẠN. Khung cố ý không giấu phần lắp ráp: mỗi dòng dưới đây làm
|
|
81
|
+
đúng một việc, xoá được, đổi thứ tự được, chèn thêm được.
|
|
82
|
+
|
|
83
|
+
Thêm module nghiệp vụ thì KHÔNG phải sửa file này — `register_routes` tự quét
|
|
84
|
+
thư mục `src/api/`. Còn thêm middleware, đổi CORS, gắn router của thư viện
|
|
85
|
+
ngoài, bọc lifespan... thì sửa ngay tại đây.
|
|
86
|
+
|
|
87
|
+
Chưa cần sửa gì thì cả khối dưới rút lại còn hai dòng:
|
|
88
|
+
|
|
89
|
+
from pymodular import create_app
|
|
90
|
+
app = create_app(AppSettings())
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
from __future__ import annotations
|
|
94
|
+
|
|
95
|
+
from pymodular import (
|
|
96
|
+
add_middleware,
|
|
97
|
+
bind_settings,
|
|
98
|
+
configure_logging,
|
|
99
|
+
new_fastapi,
|
|
100
|
+
register_error_handlers,
|
|
101
|
+
register_routes,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
from src.core.config import AppSettings
|
|
105
|
+
from src.core.lifespan import lifespan
|
|
106
|
+
|
|
107
|
+
# Đọc .env, chốt lớp cấu hình cho cả tiến trình, cắm vào DI container.
|
|
108
|
+
settings = bind_settings(AppSettings())
|
|
109
|
+
configure_logging(settings.log)
|
|
110
|
+
|
|
111
|
+
# lifespan: khung lo database và hạ tầng, phần việc riêng nằm ở
|
|
112
|
+
# src/core/lifespan.py — sửa ở đó, không phải ở đây.
|
|
113
|
+
app = new_fastapi(settings, lifespan=lifespan)
|
|
114
|
+
|
|
115
|
+
# CORS + access log + request-id. Middleware của bạn thêm sau dòng này sẽ chạy
|
|
116
|
+
# TRƯỚC ba cái đó (FastAPI chạy ngược thứ tự add).
|
|
117
|
+
add_middleware(app, settings)
|
|
118
|
+
|
|
119
|
+
# Đổi lỗi nghiệp vụ thành JSON có mã và request_id.
|
|
120
|
+
register_error_handlers(app, debug=settings.debug)
|
|
121
|
+
|
|
122
|
+
# Quét app/, gắn mọi @controller và @gateway tìm được.
|
|
123
|
+
register_routes(app, prefix=settings.api_prefix)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.get("/", include_in_schema=False)
|
|
127
|
+
async def root() -> dict[str, str]:
|
|
128
|
+
return {"service": settings.name, "version": settings.version}
|
|
129
|
+
'''
|
|
130
|
+
|
|
131
|
+
CONFIG = '''"""Cấu hình của ứng dụng — thêm biến `.env` của riêng bạn ở đây.
|
|
132
|
+
|
|
133
|
+
`Settings` mang sẵn phần của khung (database, WebSocket, RabbitMQ, Redis, MQTT,
|
|
134
|
+
Kafka, log, CORS). Kế thừa nó là đủ để pydantic-settings đọc thêm biến của bạn,
|
|
135
|
+
theo đúng quy tắc cũ: biến môi trường thắng .env, .env thắng giá trị mặc định,
|
|
136
|
+
nhóm lồng nhau ngăn bằng hai gạch dưới.
|
|
137
|
+
|
|
138
|
+
class JwtSettings(BaseModel): # -> APP_JWT__SECRET, APP_JWT__TTL_SECONDS
|
|
139
|
+
secret: str = ""
|
|
140
|
+
ttl_seconds: int = 3600
|
|
141
|
+
|
|
142
|
+
class AppSettings(Settings):
|
|
143
|
+
jwt: JwtSettings = Field(default_factory=JwtSettings, alias="APP_JWT")
|
|
144
|
+
|
|
145
|
+
Service nhận nó qua DI bằng chính lớp con, và vẫn có gợi ý kiểu đầy đủ:
|
|
146
|
+
|
|
147
|
+
@injectable
|
|
148
|
+
class TokenService:
|
|
149
|
+
def __init__(self, settings: AppSettings) -> None:
|
|
150
|
+
self._secret = settings.jwt.secret
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
from __future__ import annotations
|
|
154
|
+
|
|
155
|
+
from pydantic import Field
|
|
156
|
+
|
|
157
|
+
from pymodular import Settings
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class AppSettings(Settings):
|
|
161
|
+
"""Settings của khung, cộng thêm phần của ứng dụng."""
|
|
162
|
+
|
|
163
|
+
# Ví dụ một biến riêng — đọc từ APP_TEAM_NAME trong .env. Xoá được.
|
|
164
|
+
team_name: str = Field(default="", alias="APP_TEAM_NAME")
|
|
165
|
+
'''
|
|
166
|
+
|
|
167
|
+
HEALTH = '''"""Module mẫu — xoá được, hoặc giữ lại làm endpoint kiểm tra sức khoẻ."""
|
|
168
|
+
|
|
169
|
+
from __future__ import annotations
|
|
170
|
+
|
|
171
|
+
from pymodular import Settings, controller, get
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@controller(prefix="/health", tags=["health"])
|
|
175
|
+
class HealthController:
|
|
176
|
+
def __init__(self, settings: Settings) -> None:
|
|
177
|
+
self._settings = settings
|
|
178
|
+
|
|
179
|
+
@get("", summary="Tiến trình còn sống")
|
|
180
|
+
async def live(self) -> dict[str, str]:
|
|
181
|
+
return {"status": "ok", "service": self._settings.name}
|
|
182
|
+
'''
|
|
183
|
+
|
|
184
|
+
ENV = """APP_NAME={ten}
|
|
185
|
+
APP_ENV=local
|
|
186
|
+
APP_DEBUG=true
|
|
187
|
+
APP_HOST=0.0.0.0
|
|
188
|
+
APP_PORT=8000
|
|
189
|
+
|
|
190
|
+
# Chưa chọn database thì app chạy bằng bộ nhớ tạm (mất dữ liệu khi restart).
|
|
191
|
+
# Thêm database: pym install sqlite (hoặc postgres, mongodb)
|
|
192
|
+
# Thêm hàng đợi: pym install rabbitmq (hoặc redis, mqtt, kafka)
|
|
193
|
+
# Mỗi lệnh vừa cài thư viện, vừa ghi biến vào file này kèm giải thích.
|
|
194
|
+
#
|
|
195
|
+
# Biến của RIÊNG bạn: thêm thẳng vào đây, rồi khai trong src/core/config.py.
|
|
196
|
+
# APP_TEAM_NAME=to-backend
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
GITIGNORE = """# ---------------------------------------------------------------- Python
|
|
200
|
+
__pycache__/
|
|
201
|
+
*.py[cod]
|
|
202
|
+
*$py.class
|
|
203
|
+
*.so
|
|
204
|
+
.Python
|
|
205
|
+
|
|
206
|
+
# Gói và bản dựng
|
|
207
|
+
build/
|
|
208
|
+
dist/
|
|
209
|
+
sdist/
|
|
210
|
+
wheels/
|
|
211
|
+
*.egg
|
|
212
|
+
*.egg-info
|
|
213
|
+
.eggs/
|
|
214
|
+
MANIFEST
|
|
215
|
+
|
|
216
|
+
# Môi trường ảo
|
|
217
|
+
.venv/
|
|
218
|
+
venv/
|
|
219
|
+
ENV/
|
|
220
|
+
env/
|
|
221
|
+
.python-version
|
|
222
|
+
|
|
223
|
+
# Test, độ phủ, kiểm kiểu, lint
|
|
224
|
+
.pytest_cache/
|
|
225
|
+
.ruff_cache/
|
|
226
|
+
.mypy_cache/
|
|
227
|
+
.dmypy.json
|
|
228
|
+
.pytype/
|
|
229
|
+
.pyre/
|
|
230
|
+
.tox/
|
|
231
|
+
.nox/
|
|
232
|
+
.coverage
|
|
233
|
+
.coverage.*
|
|
234
|
+
coverage.xml
|
|
235
|
+
htmlcov/
|
|
236
|
+
*.cover
|
|
237
|
+
.hypothesis/
|
|
238
|
+
.cache/
|
|
239
|
+
|
|
240
|
+
# Jupyter
|
|
241
|
+
.ipynb_checkpoints/
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------- Dự án này
|
|
244
|
+
# .env chứa DSN và mật khẩu thật — KHÔNG BAO GIỜ commit.
|
|
245
|
+
.env
|
|
246
|
+
.env.*
|
|
247
|
+
!.env.example
|
|
248
|
+
|
|
249
|
+
data/
|
|
250
|
+
*.db
|
|
251
|
+
*.sqlite
|
|
252
|
+
*.sqlite3
|
|
253
|
+
*.log
|
|
254
|
+
logs/
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------- IDE
|
|
257
|
+
.idea/
|
|
258
|
+
.vscode/
|
|
259
|
+
.fleet/
|
|
260
|
+
.zed/
|
|
261
|
+
*.sublime-project
|
|
262
|
+
*.sublime-workspace
|
|
263
|
+
*.swp
|
|
264
|
+
*.swo
|
|
265
|
+
*~
|
|
266
|
+
|
|
267
|
+
# --------------------------------------------------------- Hệ điều hành
|
|
268
|
+
.DS_Store
|
|
269
|
+
._*
|
|
270
|
+
Thumbs.db
|
|
271
|
+
Desktop.ini
|
|
272
|
+
|
|
273
|
+
# --------------------------------------------------------------- Công cụ
|
|
274
|
+
.direnv/
|
|
275
|
+
node_modules/
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
README = """# {ten}
|
|
279
|
+
|
|
280
|
+
Dựng bằng [pymodular]({repo}) — FastAPI theo kiến trúc module kiểu NestJS.
|
|
281
|
+
|
|
282
|
+
## Chạy
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
python -m venv .venv && . .venv/bin/activate
|
|
286
|
+
pip install fastapi-modular
|
|
287
|
+
pym dev # http://localhost:8000/docs
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Cấu trúc
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
src/
|
|
294
|
+
├── main.py điểm vào: lắp ráp app — sửa thoải mái, không phải của khung
|
|
295
|
+
├── core/
|
|
296
|
+
│ ├── config.py AppSettings — thêm biến .env của riêng bạn
|
|
297
|
+
│ └── lifespan.py việc lúc khởi động / lúc tắt của riêng bạn
|
|
298
|
+
└── api/ mỗi thư mục con là một module; thêm module KHÔNG phải sửa main.py
|
|
299
|
+
└── health/
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Lệnh
|
|
303
|
+
|
|
304
|
+
Rút gọn được tới khi nào tiền tố còn chỉ đúng một lệnh: `pym mo alerts` chạy y
|
|
305
|
+
hệt `pym module alerts`. Nhập nhằng thì `pym` hỏi lại chứ không đoán.
|
|
306
|
+
|
|
307
|
+
| Lệnh | Rút gọn | Làm gì |
|
|
308
|
+
|---|---|---|
|
|
309
|
+
| `pym dev` | `pym d` | chạy kèm autoreload |
|
|
310
|
+
| `pym run --workers 4` | `pym r` | chạy chế độ production |
|
|
311
|
+
| `pym module <tên>` | `pym mo` | sinh module: controller + service + dto + entity |
|
|
312
|
+
| `pym module <tên> --gateway` | | kèm gateway WebSocket (`--consumer` cho RabbitMQ) |
|
|
313
|
+
| `pym env <thành-phần>` | `pym e` | chỉ ghi biến vào `.env`, không cài gì |
|
|
314
|
+
| `pym info` | `pym inf` | đang nối vào đâu, thư viện nào đã cài |
|
|
315
|
+
| `pym migrate` | `pym mi` | chạy migration (Alembic) |
|
|
316
|
+
| `pym test` · `pym lint` | `pym t` · `pym l` | pytest · ruff |
|
|
317
|
+
| `pym clean` | `pym c` | xoá cache và bản dựng (không đụng `data/`) |
|
|
318
|
+
| **Thêm database** | | *cài thư viện **rồi** ghi biến vào `.env`* |
|
|
319
|
+
| `pym install sqlite` | `pym ins s` | file `.db`, không cần server |
|
|
320
|
+
| `pym install postgres` | `pym ins p` | PostgreSQL |
|
|
321
|
+
| `pym install mongodb` | `pym ins mo` | MongoDB |
|
|
322
|
+
| **Thêm hàng đợi** | | *cài thư viện **rồi** ghi biến vào `.env`* |
|
|
323
|
+
| `pym install rabbitmq` | `pym ins ra` | hàng đợi bền, thử lại + DLQ |
|
|
324
|
+
| `pym install redis` | `pym ins re` | cache, đếm nguyên tử, pub/sub |
|
|
325
|
+
| `pym install mqtt` | `pym ins mq` | thiết bị IoT |
|
|
326
|
+
| `pym install kafka` | `pym ins k` | nhật ký sự kiện đọc lại được |
|
|
327
|
+
| `pym install ws-redis` | `pym ins w` | phát tin WebSocket xuyên nhiều worker |
|
|
328
|
+
| `pym install all` | `pym ins a` | tất cả những thứ trên |
|
|
329
|
+
|
|
330
|
+
`pym --help` cho danh sách đầy đủ. Host và cổng lấy từ `APP_HOST` / `APP_PORT`
|
|
331
|
+
trong `.env`.
|
|
332
|
+
|
|
333
|
+
`pym install` ghi vào `.env` mỗi biến kèm giải thích, cho biết nó **bắt buộc hay
|
|
334
|
+
tuỳ chọn** và **mặc định là gì** nếu xoá dòng đi. Không cài, không bật thì lớp đó
|
|
335
|
+
nằm im — không import thư viện, không mở kết nối, không đổi hành vi nào.
|
|
336
|
+
|
|
337
|
+
## Thêm module
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
pym module {vi_du} # controller + service + dto + entity
|
|
341
|
+
pym module {vi_du} --gateway # kèm gateway WebSocket
|
|
342
|
+
pym module {vi_du} --consumer # kèm consumer RabbitMQ
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Route xuất hiện ngay; chỉ thân hàm trong service là chưa viết (gọi vào trả 501
|
|
346
|
+
kèm tên hàm).
|
|
347
|
+
|
|
348
|
+
## Thêm biến cấu hình của riêng bạn
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
# src/core/config.py
|
|
352
|
+
from pydantic import Field
|
|
353
|
+
|
|
354
|
+
from pymodular import Settings
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class AppSettings(Settings):
|
|
358
|
+
team_name: str = Field(default="", alias="APP_TEAM_NAME")
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
# .env
|
|
363
|
+
APP_TEAM_NAME=to-backend
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Service nhận nó qua DI bằng chính lớp con:
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
@injectable
|
|
370
|
+
class TokenService:
|
|
371
|
+
def __init__(self, settings: AppSettings) -> None:
|
|
372
|
+
self._team = settings.team_name
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## Tài liệu
|
|
376
|
+
|
|
377
|
+
[{repo}/tree/main/docs]({repo}/tree/main/docs) — database, WebSocket, RabbitMQ,
|
|
378
|
+
Redis, MQTT, Kafka, cấu hình, vận hành.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def lam_sach_ten(tho: str) -> str:
|
|
383
|
+
"""Tên thư mục -> tên dự án dùng được: "Dự Án Mới" -> "du-an-moi"."""
|
|
384
|
+
bo_dau = unicodedata.normalize("NFD", tho).encode("ascii", "ignore").decode()
|
|
385
|
+
gon = re.sub(r"[^a-zA-Z0-9]+", "-", bo_dau).strip("-").lower()
|
|
386
|
+
return gon or "app"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def tao_du_an(ten: str, root: Path) -> int:
|
|
390
|
+
"""`new`: tạo thư mục mới rồi đổ file vào."""
|
|
391
|
+
if not TEN_HOP_LE.match(ten):
|
|
392
|
+
print(f"Tên dự án không hợp lệ: {ten!r}. Chữ thường, số, gạch ngang hoặc gạch dưới.")
|
|
393
|
+
return 1
|
|
394
|
+
|
|
395
|
+
dich = root / ten
|
|
396
|
+
if dich.exists() and any(dich.iterdir()):
|
|
397
|
+
print(
|
|
398
|
+
f"{dich} đã tồn tại và không rỗng — chọn tên khác, xoá nó trước, "
|
|
399
|
+
f"hoặc vào trong đó chạy `pym init`."
|
|
400
|
+
)
|
|
401
|
+
return 1
|
|
402
|
+
|
|
403
|
+
so = _ghi(dich, ten, ghi_de=True)
|
|
404
|
+
print(f"Đã tạo {dich}/ với {so} file:")
|
|
405
|
+
_in_cay(dich, ten)
|
|
406
|
+
print(f"\nChạy thử:\n cd {ten}\n pip install fastapi-modular\n pym dev")
|
|
407
|
+
print("\nRồi mở http://localhost:8000/docs")
|
|
408
|
+
return 0
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def init_du_an(root: Path, ten: str | None = None) -> int:
|
|
412
|
+
"""`init`: đổ file vào THƯ MỤC HIỆN TẠI, không tạo thêm một cấp.
|
|
413
|
+
|
|
414
|
+
Không ghi đè file nào đã có — thư mục đang có code vẫn chạy được lệnh này.
|
|
415
|
+
"""
|
|
416
|
+
dich = root.resolve()
|
|
417
|
+
ten = ten or lam_sach_ten(dich.name)
|
|
418
|
+
if not TEN_HOP_LE.match(ten):
|
|
419
|
+
print(f"Tên dự án không hợp lệ: {ten!r}. Dùng --name để đặt tên khác.")
|
|
420
|
+
return 1
|
|
421
|
+
|
|
422
|
+
da_co = [d for d in _noi_dung(ten) if (dich / d).exists()]
|
|
423
|
+
so = _ghi(dich, ten, ghi_de=False)
|
|
424
|
+
|
|
425
|
+
if not so:
|
|
426
|
+
print(f"{dich} đã có đủ file rồi, không phải làm gì.")
|
|
427
|
+
return 0
|
|
428
|
+
|
|
429
|
+
print(f"Đã thêm {so} file vào {dich} (tên dự án: {ten}):")
|
|
430
|
+
_in_cay(dich, ten, bo_qua=set(da_co))
|
|
431
|
+
if da_co:
|
|
432
|
+
print("\nGiữ nguyên file đã có, KHÔNG ghi đè:")
|
|
433
|
+
for d in da_co:
|
|
434
|
+
print(f" {d}")
|
|
435
|
+
print("\nChạy thử:\n pip install fastapi-modular\n pym dev")
|
|
436
|
+
return 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _noi_dung(ten: str) -> dict[str, str]:
|
|
440
|
+
return {
|
|
441
|
+
"src/__init__.py": GOC,
|
|
442
|
+
"src/main.py": MAIN,
|
|
443
|
+
"src/core/__init__.py": '"""Thứ dùng chung của ứng dụng: config, helper, guard riêng."""\n',
|
|
444
|
+
"src/core/config.py": CONFIG,
|
|
445
|
+
"src/core/lifespan.py": LIFESPAN,
|
|
446
|
+
"src/api/__init__.py": '"""Các module nghiệp vụ; mỗi thư mục con là một module."""\n',
|
|
447
|
+
"src/api/health/__init__.py": '"""Module health."""\n',
|
|
448
|
+
"src/api/health/health_controller.py": HEALTH,
|
|
449
|
+
".env": ENV.format(ten=ten),
|
|
450
|
+
".gitignore": GITIGNORE,
|
|
451
|
+
"README.md": README.format(ten=ten, vi_du="alerts", repo=REPO),
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _ghi(dich: Path, ten: str, *, ghi_de: bool) -> int:
|
|
456
|
+
"""Ghi file, trả về số file thật sự được tạo."""
|
|
457
|
+
so = 0
|
|
458
|
+
for duong_dan, noi_dung in _noi_dung(ten).items():
|
|
459
|
+
f = dich / duong_dan
|
|
460
|
+
if f.exists() and not ghi_de:
|
|
461
|
+
continue
|
|
462
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
463
|
+
f.write_text(noi_dung, encoding="utf-8")
|
|
464
|
+
so += 1
|
|
465
|
+
return so
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _in_cay(dich: Path, ten: str, bo_qua: set[str] | None = None) -> None:
|
|
469
|
+
for duong_dan in _noi_dung(ten):
|
|
470
|
+
if not bo_qua or duong_dan not in bo_qua:
|
|
471
|
+
print(f" {duong_dan}")
|
pymodular/cli/serve.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""`pym dev` và `pym run` — chạy ứng dụng mà không phải nhớ dòng uvicorn nào.
|
|
2
|
+
|
|
3
|
+
Khác nhau đúng một chỗ: `dev` bật autoreload và chạy MỘT tiến trình, `run` tắt
|
|
4
|
+
reload và chạy nhiều worker. Host/cổng lấy từ cấu hình (`APP_HOST`, `APP_PORT`)
|
|
5
|
+
nên đổi trong .env là đủ, không phải sửa lệnh.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _chuan_bi_sys_path() -> None:
|
|
15
|
+
"""Thêm thư mục hiện tại vào sys.path.
|
|
16
|
+
|
|
17
|
+
Lệnh `uvicorn` tự làm việc này, nhưng khi gọi uvicorn từ trong một
|
|
18
|
+
console_script thì sys.path[0] là thư mục chứa script trong venv, không phải
|
|
19
|
+
dự án — nên `src.main` sẽ không import được.
|
|
20
|
+
"""
|
|
21
|
+
cwd = str(Path.cwd())
|
|
22
|
+
if cwd not in sys.path:
|
|
23
|
+
sys.path.insert(0, cwd)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _thieu_app(target: str) -> int:
|
|
27
|
+
ten_module = target.split(":")[0]
|
|
28
|
+
print(
|
|
29
|
+
f"Không import được {ten_module!r} từ {Path.cwd()}.\n"
|
|
30
|
+
"Đứng ở thư mục gốc dự án (chỗ có app/), hoặc chỉ đường bằng --app.\n"
|
|
31
|
+
"Chưa có dự án thì tạo bằng: pym init"
|
|
32
|
+
)
|
|
33
|
+
return 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def serve(*, target: str, reload: bool, workers: int | None, host: str | None,
|
|
37
|
+
port: int | None) -> int:
|
|
38
|
+
_chuan_bi_sys_path()
|
|
39
|
+
|
|
40
|
+
import uvicorn
|
|
41
|
+
|
|
42
|
+
from pymodular.core.config import get_settings
|
|
43
|
+
|
|
44
|
+
settings = get_settings()
|
|
45
|
+
host = host or settings.host
|
|
46
|
+
port = port or settings.port
|
|
47
|
+
|
|
48
|
+
ten_module = target.split(":")[0]
|
|
49
|
+
goc = ten_module.split(".")[0]
|
|
50
|
+
if not (Path.cwd() / goc).exists() and not (Path.cwd() / f"{goc}.py").exists():
|
|
51
|
+
return _thieu_app(target)
|
|
52
|
+
|
|
53
|
+
if reload:
|
|
54
|
+
# Chỉ theo dõi package ứng dụng: theo dõi cả cây thư mục sẽ khiến mỗi
|
|
55
|
+
# lần ghi file .db hay .env cũng khởi động lại server.
|
|
56
|
+
uvicorn.run(target, host=host, port=port, reload=True, reload_dirs=[goc])
|
|
57
|
+
else:
|
|
58
|
+
uvicorn.run(target, host=host, port=port, workers=workers or 4)
|
|
59
|
+
return 0
|
|
File without changes
|
pymodular/core/clock.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Nguồn thời gian dùng chung.
|
|
2
|
+
|
|
3
|
+
Tách riêng để (a) không module nào phải import module khác chỉ vì cần lấy giờ,
|
|
4
|
+
(b) test có thể monkeypatch một chỗ duy nhất khi cần đóng băng thời gian.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from pymodular.core.compat import UTC
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def utcnow() -> datetime:
|
|
15
|
+
return datetime.now(UTC)
|
pymodular/core/compat.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Vá những khác biệt giữa các phiên bản Python được hỗ trợ (3.10+).
|
|
2
|
+
|
|
3
|
+
Gom vào một chỗ để phần còn lại của khung viết như thể chỉ có một phiên bản.
|
|
4
|
+
Bỏ hỗ trợ 3.10 thì xoá file này và sửa hai chỗ import — không phải lần mò khắp
|
|
5
|
+
nơi tìm xem cái gì cần bao nhiêu.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import timezone
|
|
13
|
+
|
|
14
|
+
# `datetime.UTC` chỉ có từ 3.11; `timezone.utc` là CÙNG một đối tượng và có ở
|
|
15
|
+
# mọi phiên bản. (ruff sẽ đòi đổi ngược lại nếu target-version trong ruff.toml
|
|
16
|
+
# bị nâng lên py311 — đừng nâng khi còn hỗ trợ 3.10.)
|
|
17
|
+
UTC = timezone.utc
|
|
18
|
+
|
|
19
|
+
# `asyncio.wait_for` ném `asyncio.TimeoutError`: từ 3.11 nó CHÍNH LÀ
|
|
20
|
+
# `TimeoutError` dựng sẵn, còn 3.10 thì là một lớp khác hẳn. Bắt bằng tên này
|
|
21
|
+
# thì đúng ở cả hai; bắt bằng `TimeoutError` trần sẽ trượt trên 3.10.
|
|
22
|
+
TimeoutErrors: tuple[type[BaseException], ...] = (asyncio.TimeoutError, TimeoutError)
|
|
23
|
+
|
|
24
|
+
if sys.version_info >= (3, 11):
|
|
25
|
+
from enum import StrEnum
|
|
26
|
+
else:
|
|
27
|
+
from enum import Enum
|
|
28
|
+
|
|
29
|
+
class StrEnum(str, Enum):
|
|
30
|
+
"""Bản 3.10 của `enum.StrEnum`.
|
|
31
|
+
|
|
32
|
+
`__str__` phải trỏ về `str.__str__`: mặc định của Enum trả về
|
|
33
|
+
"Scope.REQUEST" chứ không phải "request", và đó là kiểu khác biệt chỉ lộ
|
|
34
|
+
ra khi ai đó nội suy giá trị vào log hay JSON.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__str__ = str.__str__
|
|
38
|
+
|
|
39
|
+
__all__ = ["UTC", "StrEnum", "TimeoutErrors"]
|