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,186 @@
1
+ """Backend MongoDB dùng motor (driver async chính thức).
2
+
3
+ File này CHỈ được import khi settings chọn driver mongodb — xem `factory.py`.
4
+
5
+ Ánh xạ: mỗi entity một collection, trường `id` của entity lưu vào `_id` của
6
+ document nên không tốn thêm index.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from typing import Any, TypeVar
13
+
14
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
15
+
16
+ from pymodular.core.logging import get_logger
17
+ from pymodular.infrastructure.database.base import (
18
+ DatabaseBackend,
19
+ Filters,
20
+ Match,
21
+ active_filters,
22
+ from_document,
23
+ mapping_for,
24
+ to_document,
25
+ )
26
+
27
+ log = get_logger(__name__)
28
+
29
+ E = TypeVar("E")
30
+
31
+
32
+ def _to_mongo(doc: dict[str, Any]) -> dict[str, Any]:
33
+ out = dict(doc)
34
+ out["_id"] = out.pop("id")
35
+ return out
36
+
37
+
38
+ def _from_mongo(doc: dict[str, Any]) -> dict[str, Any]:
39
+ out = dict(doc)
40
+ out["id"] = out.pop("_id")
41
+ return out
42
+
43
+
44
+ class MongoBackend(DatabaseBackend):
45
+ name = "mongodb"
46
+
47
+ def __init__(
48
+ self,
49
+ dsn: str,
50
+ *,
51
+ database: str,
52
+ connect_timeout_seconds: float = 10.0,
53
+ query_timeout_seconds: float = 15.0,
54
+ ) -> None:
55
+ self._dsn = dsn
56
+ self._database_name = database
57
+ self._timeout_ms = int(connect_timeout_seconds * 1000)
58
+ self._query_timeout_ms = int(query_timeout_seconds * 1000)
59
+ self._client: AsyncIOMotorClient | None = None
60
+
61
+ async def startup(self) -> None:
62
+ # motor/pymongo tự dò lại server và kết nối lại; chỉ cần siết thời gian
63
+ # chờ, vì mặc định 30 giây sẽ khiến request treo rất lâu khi Mongo chết.
64
+ self._client = AsyncIOMotorClient(
65
+ self._dsn,
66
+ uuidRepresentation="standard",
67
+ serverSelectionTimeoutMS=self._timeout_ms,
68
+ connectTimeoutMS=self._timeout_ms,
69
+ # Chặn câu lệnh đã gửi: server treo thì socket đọc mãi không xong.
70
+ socketTimeoutMS=self._query_timeout_ms,
71
+ )
72
+ log.info("db.connected", backend=self.name, database=self._database_name)
73
+
74
+ async def shutdown(self) -> None:
75
+ if self._client is not None:
76
+ self._client.close()
77
+ self._client = None
78
+
79
+ async def ping(self) -> bool:
80
+ await self._client[self._database_name].command("ping") # type: ignore[index]
81
+ return True
82
+
83
+ async def create_schema(self, *entities: type) -> None:
84
+ """Mongo không cần tạo collection, nhưng index thì có.
85
+
86
+ Không có index, mọi truy vấn lọc đều quét toàn bộ collection; và không
87
+ có unique index thì hai request đồng thời cùng tạo được bản trùng.
88
+ """
89
+ for entity in entities:
90
+ mapping = mapping_for(entity)
91
+ collection = self._collection(entity)
92
+ for name, columns, is_unique in mapping.index_specs():
93
+ try:
94
+ await collection.create_index(
95
+ [(column, 1) for column in columns], unique=is_unique, name=name
96
+ )
97
+ except Exception as exc: # noqa: BLE001 - index hỏng không được làm chết app
98
+ log.error(
99
+ "db.index_failed",
100
+ collection=mapping.storage,
101
+ index=name,
102
+ columns=list(columns),
103
+ unique=is_unique,
104
+ error=str(exc).splitlines()[0],
105
+ hint="dọn document trùng rồi khởi động lại",
106
+ )
107
+ log.info("db.indexes_ready", collections=[mapping_for(e).storage for e in entities])
108
+
109
+ def _collection(self, entity: type) -> AsyncIOMotorCollection:
110
+ assert self._client is not None, "backend chưa startup()"
111
+ return self._client[self._database_name][mapping_for(entity).storage]
112
+
113
+ def _query(self, filters: Filters) -> dict[str, Any]:
114
+ query = active_filters(filters)
115
+ if "id" in query:
116
+ query["_id"] = query.pop("id")
117
+ return query
118
+
119
+ async def get(self, entity: type[E], id_: str) -> E | None:
120
+ doc = await self._collection(entity).find_one({"_id": id_})
121
+ return from_document(entity, _from_mongo(doc)) if doc else None
122
+
123
+ async def find(
124
+ self,
125
+ entity: type[E],
126
+ *,
127
+ filters: Filters,
128
+ match: Match = None,
129
+ order_by: str | None = None,
130
+ limit: int | None = None,
131
+ offset: int = 0,
132
+ ) -> list[E]:
133
+ cursor = self._collection(entity).find(self._query(filters))
134
+ if order_by:
135
+ cursor = cursor.sort(order_by, 1)
136
+
137
+ # `match=` là predicate Python, không dịch sang query Mongo được:
138
+ # phải lấy về rồi lọc. Chỉ phân trang ở DB khi không có match.
139
+ if match is None:
140
+ if offset:
141
+ cursor = cursor.skip(offset)
142
+ if limit is not None:
143
+ cursor = cursor.limit(limit)
144
+
145
+ items = [from_document(entity, _from_mongo(d)) async for d in cursor]
146
+ if match is not None:
147
+ items = [o for o in items if match(o)][offset:]
148
+ if limit is not None:
149
+ items = items[:limit]
150
+ return items
151
+
152
+ async def find_one(
153
+ self, entity: type[E], *, filters: Filters, match: Match = None
154
+ ) -> E | None:
155
+ items = await self.find(entity, filters=filters, match=match, limit=1)
156
+ return items[0] if items else None
157
+
158
+ async def count(
159
+ self, entity: type[E], *, filters: Filters, match: Match = None
160
+ ) -> int:
161
+ if match is not None:
162
+ return len(await self.find(entity, filters=filters, match=match))
163
+ return await self._collection(entity).count_documents(self._query(filters))
164
+
165
+ async def save(self, entity: type[E], obj: E) -> E:
166
+ if not getattr(obj, "id", None):
167
+ obj.id = uuid.uuid4().hex # type: ignore[attr-defined]
168
+ doc = _to_mongo(to_document(obj))
169
+ await self._collection(entity).replace_one({"_id": doc["_id"]}, doc, upsert=True)
170
+ return obj
171
+
172
+ async def delete(self, entity: type[E], id_: str) -> bool:
173
+ result = await self._collection(entity).delete_one({"_id": id_})
174
+ return result.deleted_count > 0
175
+
176
+ async def delete_where(
177
+ self, entity: type[E], *, filters: Filters, match: Match = None
178
+ ) -> int:
179
+ if match is not None:
180
+ victims = await self.find(entity, filters=filters, match=match)
181
+ removed = 0
182
+ for obj in victims:
183
+ removed += int(await self.delete(entity, obj.id)) # type: ignore[attr-defined]
184
+ return removed
185
+ result = await self._collection(entity).delete_many(self._query(filters))
186
+ return int(result.deleted_count)
@@ -0,0 +1,188 @@
1
+ """`Database` (kết nối) và `Repository[E]` (kho dữ liệu của một entity).
2
+
3
+ Service chỉ khai báo `repo: Repository[User]` là có sẵn CRUD, giống
4
+ `Repository<Camera>` của TypeORM trong NestJS. Repository không biết đang chạy
5
+ trên memory, SQL hay Mongo — nó chuyển tiếp xuống backend do `Database` chọn
6
+ theo cấu hình.
7
+
8
+ Quy ước lọc: tham số `**equals` có giá trị None đều bị BỎ QUA (coi như "không
9
+ lọc theo trường này"). Cần điều kiện phức tạp hơn bằng nhau thì dùng `match=`
10
+ với một predicate — lưu ý predicate chạy trong Python nên KHÔNG đẩy được
11
+ xuống SQL/Mongo (backend sẽ lấy dữ liệu về rồi mới lọc).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from typing import Any, Generic, TypeVar
18
+
19
+ from pymodular.core.clock import utcnow
20
+ from pymodular.core.config import Settings
21
+ from pymodular.core.container import injectable
22
+ from pymodular.core.logging import get_logger
23
+ from pymodular.infrastructure.database.base import DatabaseBackend, is_transient_error
24
+ from pymodular.infrastructure.database.factory import create_backend
25
+
26
+ log = get_logger(__name__)
27
+
28
+ E = TypeVar("E")
29
+
30
+
31
+ @injectable
32
+ class Database:
33
+ """Sở hữu kết nối tới database đang được cấu hình."""
34
+
35
+ def __init__(self, settings: Settings) -> None:
36
+ self._settings = settings
37
+ self._backend: DatabaseBackend = create_backend(settings.db)
38
+
39
+ @property
40
+ def backend(self) -> DatabaseBackend:
41
+ return self._backend
42
+
43
+ @property
44
+ def driver(self) -> str:
45
+ return self._backend.name
46
+
47
+ async def startup(self, *entities: type) -> None:
48
+ await self._backend.startup()
49
+ await self._wait_until_reachable()
50
+
51
+ create_schema = getattr(self._backend, "create_schema", None)
52
+ if create_schema is not None and entities:
53
+ await create_schema(*entities)
54
+
55
+ async def _wait_until_reachable(self) -> None:
56
+ """Thử ping vài lần trước khi bỏ cuộc.
57
+
58
+ Hay gặp với docker compose: app khởi động xong trước khi database sẵn
59
+ sàng nhận kết nối. Thử lại vài giây rẻ hơn nhiều so với để container
60
+ chết rồi chờ orchestrator dựng lại.
61
+ """
62
+ import asyncio
63
+
64
+ attempts = max(1, self._settings.db.startup_retries)
65
+ delay = self._settings.db.startup_retry_delay_seconds
66
+
67
+ for attempt in range(1, attempts + 1):
68
+ try:
69
+ await self._backend.ping()
70
+ if attempt > 1:
71
+ log.info("db.connected_after_retry", backend=self._backend.name,
72
+ attempts=attempt)
73
+ return
74
+ except Exception as exc:
75
+ reason = f"{type(exc).__name__}: {exc}"
76
+
77
+ # Sai mật khẩu / sai tên database thì thử lại vô ích, chỉ làm
78
+ # chậm lúc phát hiện cấu hình sai. Dừng ngay.
79
+ if not is_transient_error(exc):
80
+ log.error(
81
+ "db.config_error_at_startup",
82
+ backend=self._backend.name,
83
+ error=reason,
84
+ hint="lỗi này không tự hết khi thử lại — kiểm tra APP_DB__DSN",
85
+ )
86
+ raise
87
+
88
+ if attempt == attempts:
89
+ log.error(
90
+ "db.unreachable_at_startup",
91
+ backend=self._backend.name,
92
+ attempts=attempts,
93
+ error=reason,
94
+ )
95
+ raise
96
+
97
+ log.warning(
98
+ "db.retry_connect",
99
+ backend=self._backend.name,
100
+ attempt=attempt,
101
+ of=attempts,
102
+ retry_in=delay,
103
+ error=reason, # nói luôn lý do, đừng bắt chờ tới lần cuối
104
+ )
105
+ await asyncio.sleep(delay)
106
+
107
+ async def shutdown(self) -> None:
108
+ await self._backend.shutdown()
109
+
110
+ async def ping(self) -> bool:
111
+ return await self._backend.ping()
112
+
113
+
114
+ @injectable
115
+ class Repository(Generic[E]):
116
+ """CRUD sẵn có cho một entity, bất kể backend nào bên dưới."""
117
+
118
+ def __init__(self, entity: type[E], database: Database) -> None:
119
+ self._entity = entity
120
+ self._db = database
121
+
122
+ @property
123
+ def _backend(self) -> DatabaseBackend:
124
+ return self._db.backend
125
+
126
+ # ------------------------------------------------------------------ đọc
127
+ async def get(self, id_: str) -> E | None:
128
+ return await self._backend.get(self._entity, id_)
129
+
130
+ async def find(
131
+ self,
132
+ *,
133
+ match: Callable[[E], bool] | None = None,
134
+ order_by: str | None = "created_at",
135
+ limit: int | None = None,
136
+ offset: int = 0,
137
+ **equals: Any,
138
+ ) -> list[E]:
139
+ return await self._backend.find(
140
+ self._entity,
141
+ filters=equals,
142
+ match=match,
143
+ order_by=order_by,
144
+ limit=limit,
145
+ offset=offset,
146
+ )
147
+
148
+ async def find_one(
149
+ self, *, match: Callable[[E], bool] | None = None, **equals: Any
150
+ ) -> E | None:
151
+ return await self._backend.find_one(self._entity, filters=equals, match=match)
152
+
153
+ async def count(
154
+ self, *, match: Callable[[E], bool] | None = None, **equals: Any
155
+ ) -> int:
156
+ return await self._backend.count(self._entity, filters=equals, match=match)
157
+
158
+ async def exists(
159
+ self, *, match: Callable[[E], bool] | None = None, **equals: Any
160
+ ) -> bool:
161
+ return await self.find_one(match=match, **equals) is not None
162
+
163
+ # ------------------------------------------------------------------ ghi
164
+ async def save(self, obj: E) -> E:
165
+ """Upsert. Tự sinh id nếu entity chưa có, và tự đóng dấu `updated_at`.
166
+
167
+ Đóng dấu ở đây chứ không ở service: mọi đường ghi đều đi qua save(),
168
+ nên không có chỗ nào quên. Tương đương @UpdateDateColumn của TypeORM.
169
+ Entity không có trường `updated_at` thì bỏ qua.
170
+ """
171
+ if hasattr(obj, "updated_at"):
172
+ # Bản ghi mới: cho updated_at trùng created_at thay vì lệch vài
173
+ # micro giây, để "chưa từng sửa" nhận ra được bằng created == updated.
174
+ is_new = not getattr(obj, "id", None)
175
+ created = getattr(obj, "created_at", None)
176
+ obj.updated_at = created if (is_new and created is not None) else utcnow() # type: ignore[attr-defined]
177
+ return await self._backend.save(self._entity, obj)
178
+
179
+ async def delete(self, id_: str) -> bool:
180
+ return await self._backend.delete(self._entity, id_)
181
+
182
+ async def delete_where(
183
+ self, *, match: Callable[[E], bool] | None = None, **equals: Any
184
+ ) -> int:
185
+ return await self._backend.delete_where(self._entity, filters=equals, match=match)
186
+
187
+ def __repr__(self) -> str:
188
+ return f"<Repository[{self._entity.__name__}] on {self._db.driver}>"