svc-infra 0.1.595__py3-none-any.whl → 1.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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
3
+ import builtins
4
+ from collections.abc import Iterable, Sequence
5
+ from datetime import UTC
6
+ from typing import Any, cast
4
7
 
5
- from bson import ObjectId
8
+ try:
9
+ from bson import ObjectId
10
+
11
+ _HAS_BSON = True
12
+ except ModuleNotFoundError:
13
+ # `bson` is provided by the optional `pymongo` dependency.
14
+ # Keep imports working for non-mongo users/tests; runtime Mongo usage still
15
+ # requires installing pymongo.
16
+ _HAS_BSON = False
17
+
18
+ class ObjectId: # type: ignore[no-redef]
19
+ pass
6
20
 
7
21
 
8
22
  class NoSqlRepository:
@@ -24,7 +38,7 @@ class NoSqlRepository:
24
38
  soft_delete: bool = False,
25
39
  soft_delete_field: str = "deleted_at",
26
40
  soft_delete_flag_field: str | None = None,
27
- immutable_fields: Optional[set[str]] = None,
41
+ immutable_fields: set[str] | None = None,
28
42
  ):
29
43
  self.collection_name = collection_name
30
44
  self.id_field = id_field
@@ -35,7 +49,7 @@ class NoSqlRepository:
35
49
  immutable_fields or {self.id_field, "created_at", "updated_at"}
36
50
  )
37
51
 
38
- def _alive_filter(self) -> Dict[str, Any]:
52
+ def _alive_filter(self) -> dict[str, Any]:
39
53
  """
40
54
  Build a filter that returns 'alive' docs when soft_delete is enabled.
41
55
  - deleted_at is either null or absent
@@ -73,16 +87,18 @@ class NoSqlRepository:
73
87
 
74
88
  return clauses[0] if len(clauses) == 1 else {"$and": clauses}
75
89
 
76
- def _merge_and(self, *filters: Optional[Dict[str, Any]]) -> Dict[str, Any]:
90
+ def _merge_and(self, *filters: dict[str, Any] | None) -> dict[str, Any]:
77
91
  parts = [f for f in filters if f]
78
92
  if not parts:
79
93
  return {}
80
94
  if len(parts) == 1:
81
- return parts[0] # type: ignore[return-value]
95
+ return parts[0]
82
96
  return {"$and": parts}
83
97
 
84
98
  def _normalize_id_value(self, val: Any) -> Any:
85
99
  """If we use Mongo’s _id and a string is passed, coerce to ObjectId when possible."""
100
+ if not _HAS_BSON:
101
+ return val
86
102
  if self.id_field == "_id" and isinstance(val, str):
87
103
  try:
88
104
  return ObjectId(val)
@@ -91,13 +107,14 @@ class NoSqlRepository:
91
107
  return val
92
108
 
93
109
  @staticmethod
94
- def _public_doc(doc: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
95
- if not doc:
96
- return doc
110
+ def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
97
111
  d = dict(doc)
98
112
  if "_id" in d and "id" not in d:
99
113
  _id = d.pop("_id", None)
100
- d["id"] = str(_id) if isinstance(_id, ObjectId) else _id
114
+ if _HAS_BSON and isinstance(_id, ObjectId):
115
+ d["id"] = str(_id)
116
+ else:
117
+ d["id"] = _id
101
118
  return d
102
119
 
103
120
  async def list(
@@ -106,26 +123,28 @@ class NoSqlRepository:
106
123
  *,
107
124
  limit: int,
108
125
  offset: int,
109
- sort: Optional[List[Tuple[str, int]]] = None,
110
- filter: Optional[Dict[str, Any]] = None,
111
- ) -> List[Dict]:
126
+ sort: builtins.list[tuple[str, int]] | None = None,
127
+ filter: dict[str, Any] | None = None,
128
+ ) -> builtins.list[dict[str, Any]]:
112
129
  filt = self._merge_and(self._alive_filter(), filter)
113
130
  cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
114
131
  if sort:
115
132
  cursor = cursor.sort(sort)
116
133
  return [self._public_doc(doc) async for doc in cursor]
117
134
 
118
- async def count(self, db, *, filter: Optional[Dict[str, Any]] = None) -> int:
135
+ async def count(self, db, *, filter: dict[str, Any] | None = None) -> int:
119
136
  filt = self._merge_and(self._alive_filter(), filter)
120
- return await db[self.collection_name].count_documents(filt or {})
137
+ return cast("int", await db[self.collection_name].count_documents(filt or {}))
121
138
 
122
- async def get(self, db, id_value: Any) -> Dict | None:
139
+ async def get(self, db, id_value: Any) -> dict | None:
123
140
  id_value = self._normalize_id_value(id_value)
124
141
  filt = self._merge_and(self._alive_filter(), {self.id_field: id_value})
125
142
  doc = await db[self.collection_name].find_one(filt)
143
+ if doc is None:
144
+ return None
126
145
  return self._public_doc(doc)
127
146
 
128
- async def create(self, db, data: Dict[str, Any]) -> Dict[str, Any]:
147
+ async def create(self, db, data: dict[str, Any]) -> dict[str, Any]:
129
148
  # don't let clients supply soft-delete artifacts on create
130
149
  if self.soft_delete:
131
150
  data.pop(self.soft_delete_field, None)
@@ -134,7 +153,7 @@ class NoSqlRepository:
134
153
  res = await db[self.collection_name].insert_one(data)
135
154
  return self._public_doc({**data, "_id": res.inserted_id})
136
155
 
137
- async def update(self, db, id_value: Any, data: Dict[str, Any]) -> Dict | None:
156
+ async def update(self, db, id_value: Any, data: dict[str, Any]) -> dict | None:
138
157
  for k in list(data.keys()):
139
158
  if k in self.immutable_fields:
140
159
  data.pop(k, None)
@@ -146,19 +165,19 @@ class NoSqlRepository:
146
165
  async def delete(self, db, id_value: Any) -> bool:
147
166
  id_value = self._normalize_id_value(id_value)
148
167
  if self.soft_delete:
149
- set_ops: Dict[str, Any] = {}
168
+ set_ops: dict[str, Any] = {}
150
169
  if self.soft_delete_flag_field:
151
170
  set_ops[self.soft_delete_flag_field] = False
152
- from datetime import datetime, timezone
171
+ from datetime import datetime
153
172
 
154
- set_ops[self.soft_delete_field] = datetime.now(timezone.utc)
173
+ set_ops[self.soft_delete_field] = datetime.now(UTC)
155
174
  res = await db[self.collection_name].update_one(
156
175
  {self.id_field: id_value}, {"$set": set_ops}
157
176
  )
158
- return res.modified_count > 0
177
+ return cast("int", res.modified_count) > 0
159
178
 
160
179
  res = await db[self.collection_name].delete_one({self.id_field: id_value})
161
- return res.deleted_count > 0
180
+ return cast("int", res.deleted_count) > 0
162
181
 
163
182
  async def search(
164
183
  self,
@@ -168,8 +187,8 @@ class NoSqlRepository:
168
187
  fields: Sequence[str],
169
188
  limit: int,
170
189
  offset: int,
171
- sort: Optional[List[Tuple[str, int]]] = None,
172
- ) -> List[Dict]:
190
+ sort: builtins.list[tuple[str, int]] | None = None,
191
+ ) -> builtins.list[dict[str, Any]]:
173
192
  regex = {"$regex": q, "$options": "i"}
174
193
  or_filter = [{"$or": [{f: regex} for f in fields]}] if fields else []
175
194
  filt = (
@@ -184,9 +203,9 @@ class NoSqlRepository:
184
203
  regex = {"$regex": q, "$options": "i"}
185
204
  or_filter = {"$or": [{f: regex} for f in fields]} if fields else {}
186
205
  filt = self._merge_and(self._alive_filter(), or_filter)
187
- return await db[self.collection_name].count_documents(filt or {})
206
+ return cast("int", await db[self.collection_name].count_documents(filt or {}))
188
207
 
189
- async def exists(self, db, *, where: Iterable[Dict[str, Any]]) -> bool:
208
+ async def exists(self, db, *, where: Iterable[dict[str, Any]]) -> bool:
190
209
  filt = self._merge_and(self._alive_filter(), *list(where))
191
210
  doc = await db[self.collection_name].find_one(filt, projection={self.id_field: 1})
192
211
  return doc is not None
@@ -1,9 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable, Iterable, Sequence
3
4
  from dataclasses import dataclass
4
- from typing import Any, Callable, Iterable, Optional, Sequence, Type, Union
5
-
6
- from pymongo import IndexModel
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ )
9
+
10
+ if TYPE_CHECKING:
11
+ from pymongo import IndexModel
12
+ else:
13
+ try:
14
+ from pymongo import IndexModel
15
+ except ModuleNotFoundError:
16
+ # Minimal runtime stub so importing svc_infra works without optional Mongo deps.
17
+ class IndexModel: # type: ignore[no-redef]
18
+ pass
7
19
 
8
20
 
9
21
  def _snake(name: str) -> str:
@@ -39,36 +51,36 @@ class NoSqlResource:
39
51
  """
40
52
 
41
53
  # API mounting
42
- collection: Optional[str] = None
54
+ collection: str | None = None
43
55
  prefix: str = ""
44
- document_model: Optional[Type[Any]] = None
56
+ document_model: type[Any] | None = None
45
57
 
46
58
  # optional Pydantic schemas (auto-derived if omitted)
47
- read_schema: Optional[Type[Any]] = None
48
- create_schema: Optional[Type[Any]] = None
49
- update_schema: Optional[Type[Any]] = None
59
+ read_schema: type[Any] | None = None
60
+ create_schema: type[Any] | None = None
61
+ update_schema: type[Any] | None = None
50
62
 
51
63
  # behavior
52
- search_fields: Optional[Sequence[str]] = None
53
- tags: Optional[list[str]] = None
64
+ search_fields: Sequence[str] | None = None
65
+ tags: list[str] | None = None
54
66
  id_field: str = "_id"
55
67
  soft_delete: bool = False
56
68
  soft_delete_field: str = "deleted_at"
57
- soft_delete_flag_field: Optional[str] = None
69
+ soft_delete_flag_field: str | None = None
58
70
 
59
71
  # custom wiring
60
- service_factory: Optional[Callable[[Any], Any]] = None
72
+ service_factory: Callable[[Any], Any] | None = None
61
73
 
62
74
  # generated schema naming and exclusions
63
- read_name: Optional[str] = None
64
- create_name: Optional[str] = None
65
- update_name: Optional[str] = None
75
+ read_name: str | None = None
76
+ create_name: str | None = None
77
+ update_name: str | None = None
66
78
  create_exclude: tuple[str, ...] = ("_id",)
67
79
  read_exclude: tuple[str, ...] = ()
68
80
  update_exclude: tuple[str, ...] = ()
69
81
 
70
82
  # NEW: indexes defined per collection (normalized to IndexModel at prepare time)
71
- indexes: Optional[Iterable[Union[IndexModel, IndexAlias]]] = None
83
+ indexes: Iterable[IndexModel | IndexAlias] | None = None
72
84
 
73
85
  def __post_init__(self):
74
86
  if not self.collection and self.document_model:
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import Any, Dict, Literal, Optional
4
+ from typing import Any, Literal
5
5
 
6
6
  from svc_infra.db.utils import normalize_dir, pascal, plural_snake, snake
7
7
  from svc_infra.utils import ensure_init_py, render_template, write
@@ -10,7 +10,7 @@ _INIT_CONTENT_PAIRED = 'from . import documents, schemas\n\n__all__ = ["document
10
10
  _INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
11
11
 
12
12
 
13
- def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> Dict[str, Any]:
13
+ def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> dict[str, Any]:
14
14
  content = _INIT_CONTENT_PAIRED if paired else _INIT_CONTENT_MINIMAL
15
15
  return ensure_init_py(dir_path, overwrite, paired, content)
16
16
 
@@ -27,9 +27,9 @@ def scaffold_core(
27
27
  entity_name: str = "Item",
28
28
  overwrite: bool = False,
29
29
  same_dir: bool = False,
30
- documents_filename: Optional[str] = None,
31
- schemas_filename: Optional[str] = None,
32
- ) -> Dict[str, Any]:
30
+ documents_filename: str | None = None,
31
+ schemas_filename: str | None = None,
32
+ ) -> dict[str, Any]:
33
33
  """Create starter Mongo document model + CRUD schemas."""
34
34
 
35
35
  documents_dir = normalize_dir(documents_dir)
@@ -76,8 +76,8 @@ def scaffold_documents_core(
76
76
  dest_dir: Path | str,
77
77
  entity_name: str = "Item",
78
78
  overwrite: bool = False,
79
- documents_filename: Optional[str] = None,
80
- ) -> Dict[str, Any]:
79
+ documents_filename: str | None = None,
80
+ ) -> dict[str, Any]:
81
81
  dest = normalize_dir(dest_dir)
82
82
  ent = pascal(entity_name)
83
83
  coll = plural_snake(ent)
@@ -98,12 +98,14 @@ def scaffold_schemas_core(
98
98
  dest_dir: Path | str,
99
99
  entity_name: str = "Item",
100
100
  overwrite: bool = False,
101
- schemas_filename: Optional[str] = None,
102
- ) -> Dict[str, Any]:
101
+ schemas_filename: str | None = None,
102
+ ) -> dict[str, Any]:
103
103
  dest = normalize_dir(dest_dir)
104
104
  ent = pascal(entity_name)
105
105
  txt = render_template(
106
- tmpl_dir="svc_infra.db.nosql.mongo.templates", name="schemas.py.tmpl", subs={"Entity": ent}
106
+ tmpl_dir="svc_infra.db.nosql.mongo.templates",
107
+ name="schemas.py.tmpl",
108
+ subs={"Entity": ent},
107
109
  )
108
110
  filename = schemas_filename or f"{snake(entity_name)}.py"
109
111
  res = write(dest / filename, txt, overwrite)
@@ -115,9 +117,9 @@ def scaffold_resources_core(
115
117
  *,
116
118
  dest_dir: Path | str,
117
119
  entity_name: str = "Item",
118
- filename: Optional[str] = None, # defaults to "resources.py"
120
+ filename: str | None = None, # defaults to "resources.py"
119
121
  overwrite: bool = False,
120
- ) -> Dict[str, Any]:
122
+ ) -> dict[str, Any]:
121
123
  """
122
124
  Create a starter resources.py with:
123
125
  - empty RESOURCES list
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Sequence
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
6
  from .repository import NoSqlRepository
6
7
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Optional
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from .service import NoSqlService
6
7
 
@@ -11,8 +12,8 @@ class NoSqlServiceWithHooks(NoSqlService):
11
12
  def __init__(
12
13
  self,
13
14
  repo,
14
- pre_create: Optional[PreHook] = None,
15
- pre_update: Optional[PreHook] = None,
15
+ pre_create: PreHook | None = None,
16
+ pre_update: PreHook | None = None,
16
17
  ):
17
18
  super().__init__(repo)
18
19
  self._pre_create = pre_create
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from collections.abc import Sequence
4
5
  from pathlib import Path
5
- from typing import Optional, Sequence
6
6
 
7
7
  from dotenv import load_dotenv
8
8
 
@@ -27,7 +27,7 @@ def prepare_process_env(project_root: Path | str) -> Path:
27
27
  return root
28
28
 
29
29
 
30
- def _read_secret_from_file(path: str) -> Optional[str]:
30
+ def _read_secret_from_file(path: str) -> str | None:
31
31
  try:
32
32
  p = Path(path)
33
33
  if p.exists():
@@ -40,7 +40,7 @@ def _read_secret_from_file(path: str) -> Optional[str]:
40
40
  def get_mongo_url_from_env(
41
41
  required: bool = True,
42
42
  env_vars: Sequence[str] = DEFAULT_MONGO_ENV_VARS,
43
- ) -> Optional[str]:
43
+ ) -> str | None:
44
44
  """
45
45
  Resolve the Mongo connection string with support for:
46
46
  - Primary env vars (DEFAULT_MONGO_ENV_VARS).
@@ -99,7 +99,7 @@ def get_mongo_dbname_from_env(
99
99
  required: bool = False,
100
100
  env_vars: Sequence[str] = DEFAULT_MONGO_DB_ENV_VARS,
101
101
  default: str = "app",
102
- ) -> Optional[str]:
102
+ ) -> str | None:
103
103
  """Return a database name from env; optional (Motor can connect without it)."""
104
104
  load_dotenv(override=False)
105
105
  for key in env_vars: