svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Iterable, Sequence
4
+ from typing import Any, Iterable, Sequence, cast
5
5
 
6
- from motor.motor_asyncio import AsyncIOMotorDatabase
7
- from pymongo import IndexModel
6
+ try:
7
+ from motor.motor_asyncio import AsyncIOMotorDatabase
8
+ from pymongo import IndexModel
9
+
10
+ HAS_MOTOR = True
11
+ except ImportError: # pragma: no cover
12
+ HAS_MOTOR = False
13
+ AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
14
+ IndexModel = Any # type: ignore[assignment, misc]
8
15
 
9
16
  from svc_infra.db.nosql.indexes import normalize_indexes
10
- from svc_infra.db.nosql.mongo.client import acquire_db, close_mongo, init_mongo, ping_mongo
17
+ from svc_infra.db.nosql.mongo.client import (
18
+ acquire_db,
19
+ close_mongo,
20
+ init_mongo,
21
+ ping_mongo,
22
+ )
11
23
  from svc_infra.db.nosql.resource import NoSqlResource
12
24
  from svc_infra.db.nosql.utils import (
13
25
  get_mongo_dbname_from_env,
@@ -34,7 +46,8 @@ async def _apply_indexes(
34
46
  ) -> list[str]:
35
47
  if not indexes:
36
48
  return []
37
- return await db[collection].create_indexes(list(indexes))
49
+ result = await db[collection].create_indexes(list(indexes))
50
+ return cast(list[str], result)
38
51
 
39
52
 
40
53
  # collection + doc used to "lock" the chosen DB name for this app
@@ -48,7 +61,9 @@ async def assert_db_locked(
48
61
  registry = db.client.get_database(_REG_DB)
49
62
  await registry[_REG_COLL].create_index("service_id", unique=True)
50
63
 
51
- doc = await registry[_REG_COLL].find_one({"service_id": service_id}, projection={"db_name": 1})
64
+ doc = await registry[_REG_COLL].find_one(
65
+ {"service_id": service_id}, projection={"db_name": 1}
66
+ )
52
67
  if doc is None:
53
68
  await registry[_REG_COLL].insert_one(
54
69
  {"service_id": service_id, "db_name": expected_db_name}
@@ -91,9 +106,13 @@ async def prepare_mongo(
91
106
 
92
107
  expected_db = get_mongo_dbname_from_env(required=True)
93
108
  if db.name != expected_db:
94
- raise RuntimeError(f"Connected to Mongo DB '{db.name}', but env says '{expected_db}'.")
109
+ raise RuntimeError(
110
+ f"Connected to Mongo DB '{db.name}', but env says '{expected_db}'."
111
+ )
95
112
 
96
- await assert_db_locked(db, expected_db, service_id=service_id, allow_rebind=allow_rebind)
113
+ await assert_db_locked(
114
+ db, expected_db, service_id=service_id, allow_rebind=allow_rebind
115
+ )
97
116
 
98
117
  # collections
99
118
  colls = [r.resolved_collection() for r in resources]
@@ -109,7 +128,9 @@ async def prepare_mongo(
109
128
  names = await _apply_indexes(db, collection=coll, indexes=idx_models)
110
129
  created_idx[coll] = names
111
130
 
112
- return PrepareResult(ok=True, created_collections=created_colls, created_indexes=created_idx)
131
+ return PrepareResult(
132
+ ok=True, created_collections=created_colls, created_indexes=created_idx
133
+ )
113
134
 
114
135
 
115
136
  def setup_and_prepare(
@@ -66,7 +66,9 @@ def normalize_index(idx: Union[IndexModel, Alias]) -> IndexModel:
66
66
  return IndexModel(keys, **kwargs)
67
67
 
68
68
 
69
- def normalize_indexes(indexes: Optional[Iterable[Union[IndexModel, Alias]]]) -> List[IndexModel]:
69
+ def normalize_indexes(
70
+ indexes: Optional[Iterable[Union[IndexModel, Alias]]],
71
+ ) -> List[IndexModel]:
70
72
  if not indexes:
71
73
  return []
72
74
  return [normalize_index(i) for i in indexes]
@@ -83,7 +83,7 @@ def make_document_crud_schemas(
83
83
  )
84
84
 
85
85
  # Backstop encoders in case any exotic types slip through
86
- encoders = {ObjectId: str, PyObjectId: str}
86
+ encoders: dict[type, Any] = {ObjectId: str, PyObjectId: str}
87
87
  if json_encoders:
88
88
  encoders.update(json_encoders)
89
89
 
@@ -29,17 +29,17 @@ We provide four CLI commands. You can register them on your Typer app or invoke
29
29
 
30
30
  ### Commands
31
31
 
32
- - `mongo-scaffold` — create both document **and** CRUD schemas
33
- - `mongo-scaffold-documents` — create only the **document** model (Pydantic)
34
- - `mongo-scaffold-schemas` — create only the **CRUD schemas**
35
- - `mongo-scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
32
+ - `mongo scaffold` — create both document **and** CRUD schemas
33
+ - `mongo scaffold-documents` — create only the **document** model (Pydantic)
34
+ - `mongo scaffold-schemas` — create only the **CRUD schemas**
35
+ - `mongo scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
36
36
 
37
37
  ### Typical usage
38
38
 
39
39
  #### A) Scaffold documents + schemas together
40
40
 
41
41
  ```bash
42
- yourapp mongo-scaffold \
42
+ yourapp mongo scaffold \
43
43
  --entity-name Product \
44
44
  --documents-dir ./src/your_app/products \
45
45
  --schemas-dir ./src/your_app/products \
@@ -57,7 +57,7 @@ src/your_app/products/schemas.py # ProductRead/ProductCreate/ProductUpdate
57
57
  B) Documents only
58
58
 
59
59
  ```bash
60
- yourapp mongo-scaffold-documents \
60
+ yourapp mongo scaffold-documents \
61
61
  --dest-dir ./src/your_app/products \
62
62
  --entity-name Product \
63
63
  --documents-filename product_doc.py
@@ -66,7 +66,7 @@ yourapp mongo-scaffold-documents \
66
66
  C) Schemas only
67
67
 
68
68
  ```bash
69
- yourapp mongo-scaffold-schemas \
69
+ yourapp mongo scaffold-schemas \
70
70
  --dest-dir ./src/your_app/products \
71
71
  --entity-name Product \
72
72
  --schemas-filename product_schemas.py
@@ -75,7 +75,7 @@ yourapp mongo-scaffold-schemas \
75
75
  D) Starter resources.py
76
76
 
77
77
  ```bash
78
- yourapp mongo-scaffold-resources \
78
+ yourapp mongo scaffold-resources \
79
79
  --dest-dir ./src/your_app/mongo \
80
80
  --filename resources.py \
81
81
  --overwrite
@@ -131,7 +131,7 @@ There are two flavors:
131
131
  A) Async, minimal (connect, create collections, apply indexes)
132
132
 
133
133
  ```bash
134
- yourapp mongo-prepare \
134
+ yourapp mongo prepare \
135
135
  --resources your_app.mongo.resources:RESOURCES \
136
136
  --mongo-url "$MONGO_URL" \
137
137
  --mongo-db "$MONGO_DB"
@@ -140,7 +140,7 @@ yourapp mongo-prepare \
140
140
  B) Synchronous wrapper (end-to-end convenience)
141
141
 
142
142
  ```bash
143
- yourapp mongo-setup-and-prepare \
143
+ yourapp mongo setup-and-prepare \
144
144
  --resources your_app.mongo.resources:RESOURCES \
145
145
  --mongo-url "$MONGO_URL" \
146
146
  --mongo-db "$MONGO_DB"
@@ -149,7 +149,7 @@ yourapp mongo-setup-and-prepare \
149
149
  You can also ping connectivity:
150
150
 
151
151
  ```bash
152
- yourapp mongo-ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
152
+ yourapp mongo ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
153
153
  ```
154
154
 
155
155
  Behind the scenes, preparation also locks a service ID to a DB name to prevent accidental cross-DB usage. You can pass --allow-rebind if you intentionally move environments.
@@ -430,9 +430,9 @@ NoSqlResource(
430
430
  • If using explicit schemas with PyObjectId, make sure model_config.json_encoders includes {PyObjectId: str}.
431
431
  • When using auto-schemas, we expose ObjectId-like fields as str so no custom encoder is needed.
432
432
  • Connected to wrong DB name
433
- • The system locks a service_id to the DB name once prepared. If you change DBs, run mongo-prepare with --allow-rebind.
433
+ • The system locks a service_id to the DB name once prepared. If you change DBs, run `mongo prepare` with --allow-rebind.
434
434
  • Indexes not created
435
- • Double-check RESOURCES[indexes]. Run mongo-prepare again and inspect the output dictionary of created indexes.
435
+ • Double-check RESOURCES[indexes]. Run `mongo prepare` again and inspect the output dictionary of created indexes.
436
436
 
437
437
 
438
438
 
@@ -1,8 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Optional
3
+ from typing import Any, Optional
4
4
 
5
- from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
5
+ try:
6
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
7
+
8
+ HAS_MOTOR = True
9
+ except ImportError: # pragma: no cover
10
+ HAS_MOTOR = False
11
+ AsyncIOMotorClient = Any # type: ignore[assignment, misc]
12
+ AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
6
13
 
7
14
  from .settings import MongoSettings
8
15
 
@@ -10,6 +17,15 @@ _client: Optional[AsyncIOMotorClient] = None
10
17
  _db: Optional[AsyncIOMotorDatabase] = None
11
18
 
12
19
 
20
+ def _require_motor() -> None:
21
+ """Raise ImportError if motor is not installed."""
22
+ if not HAS_MOTOR:
23
+ raise ImportError(
24
+ "MongoDB support requires the 'motor' package. "
25
+ "Install with: pip install svc-infra[mongodb]"
26
+ )
27
+
28
+
13
29
  def _client_opts(cfg: MongoSettings) -> dict:
14
30
  return {
15
31
  "appname": cfg.appname,
@@ -20,6 +36,7 @@ def _client_opts(cfg: MongoSettings) -> dict:
20
36
 
21
37
 
22
38
  async def init_mongo(cfg: MongoSettings | None = None) -> AsyncIOMotorDatabase:
39
+ _require_motor()
23
40
  global _client, _db
24
41
  cfg = cfg or MongoSettings()
25
42
  if _client is None:
@@ -6,8 +6,12 @@ from pydantic import AnyUrl, BaseModel, Field
6
6
 
7
7
 
8
8
  class MongoSettings(BaseModel):
9
- url: AnyUrl = Field(default_factory=lambda: os.getenv("MONGO_URL", "mongodb://localhost:27017"))
9
+ url: AnyUrl = Field(
10
+ default_factory=lambda: os.getenv("MONGO_URL", "mongodb://localhost:27017")
11
+ ) # type: ignore[assignment]
10
12
  db_name: str = Field(default_factory=lambda: os.getenv("MONGO_DB", ""))
11
- appname: str = Field(default_factory=lambda: os.getenv("MONGO_APPNAME", "svc-infra"))
13
+ appname: str = Field(
14
+ default_factory=lambda: os.getenv("MONGO_APPNAME", "svc-infra")
15
+ )
12
16
  min_pool_size: int = int(os.getenv("MONGO_MIN_POOL", "0"))
13
17
  max_pool_size: int = int(os.getenv("MONGO_MAX_POOL", "100"))
@@ -1,8 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
3
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast
4
4
 
5
- from bson import ObjectId
5
+ try:
6
+ from bson import ObjectId
7
+
8
+ _HAS_BSON = True
9
+ except ModuleNotFoundError:
10
+ # `bson` is provided by the optional `pymongo` dependency.
11
+ # Keep imports working for non-mongo users/tests; runtime Mongo usage still
12
+ # requires installing pymongo.
13
+ _HAS_BSON = False
14
+
15
+ class ObjectId: # type: ignore[no-redef]
16
+ pass
6
17
 
7
18
 
8
19
  class NoSqlRepository:
@@ -78,11 +89,13 @@ class NoSqlRepository:
78
89
  if not parts:
79
90
  return {}
80
91
  if len(parts) == 1:
81
- return parts[0] # type: ignore[return-value]
92
+ return parts[0]
82
93
  return {"$and": parts}
83
94
 
84
95
  def _normalize_id_value(self, val: Any) -> Any:
85
96
  """If we use Mongo’s _id and a string is passed, coerce to ObjectId when possible."""
97
+ if not _HAS_BSON:
98
+ return val
86
99
  if self.id_field == "_id" and isinstance(val, str):
87
100
  try:
88
101
  return ObjectId(val)
@@ -91,13 +104,14 @@ class NoSqlRepository:
91
104
  return val
92
105
 
93
106
  @staticmethod
94
- def _public_doc(doc: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
95
- if not doc:
96
- return doc
107
+ def _public_doc(doc: Dict[str, Any]) -> Dict[str, Any]:
97
108
  d = dict(doc)
98
109
  if "_id" in d and "id" not in d:
99
110
  _id = d.pop("_id", None)
100
- d["id"] = str(_id) if isinstance(_id, ObjectId) else _id
111
+ if _HAS_BSON and isinstance(_id, ObjectId):
112
+ d["id"] = str(_id)
113
+ else:
114
+ d["id"] = _id
101
115
  return d
102
116
 
103
117
  async def list(
@@ -108,7 +122,7 @@ class NoSqlRepository:
108
122
  offset: int,
109
123
  sort: Optional[List[Tuple[str, int]]] = None,
110
124
  filter: Optional[Dict[str, Any]] = None,
111
- ) -> List[Dict]:
125
+ ) -> List[Dict[str, Any]]:
112
126
  filt = self._merge_and(self._alive_filter(), filter)
113
127
  cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
114
128
  if sort:
@@ -117,12 +131,14 @@ class NoSqlRepository:
117
131
 
118
132
  async def count(self, db, *, filter: Optional[Dict[str, Any]] = None) -> int:
119
133
  filt = self._merge_and(self._alive_filter(), filter)
120
- return await db[self.collection_name].count_documents(filt or {})
134
+ return cast(int, await db[self.collection_name].count_documents(filt or {}))
121
135
 
122
136
  async def get(self, db, id_value: Any) -> Dict | None:
123
137
  id_value = self._normalize_id_value(id_value)
124
138
  filt = self._merge_and(self._alive_filter(), {self.id_field: id_value})
125
139
  doc = await db[self.collection_name].find_one(filt)
140
+ if doc is None:
141
+ return None
126
142
  return self._public_doc(doc)
127
143
 
128
144
  async def create(self, db, data: Dict[str, Any]) -> Dict[str, Any]:
@@ -155,10 +171,10 @@ class NoSqlRepository:
155
171
  res = await db[self.collection_name].update_one(
156
172
  {self.id_field: id_value}, {"$set": set_ops}
157
173
  )
158
- return res.modified_count > 0
174
+ return cast(int, res.modified_count) > 0
159
175
 
160
176
  res = await db[self.collection_name].delete_one({self.id_field: id_value})
161
- return res.deleted_count > 0
177
+ return cast(int, res.deleted_count) > 0
162
178
 
163
179
  async def search(
164
180
  self,
@@ -169,11 +185,13 @@ class NoSqlRepository:
169
185
  limit: int,
170
186
  offset: int,
171
187
  sort: Optional[List[Tuple[str, int]]] = None,
172
- ) -> List[Dict]:
188
+ ) -> List[Dict[str, Any]]:
173
189
  regex = {"$regex": q, "$options": "i"}
174
190
  or_filter = [{"$or": [{f: regex} for f in fields]}] if fields else []
175
191
  filt = (
176
- self._merge_and(self._alive_filter(), *or_filter) if or_filter else self._alive_filter()
192
+ self._merge_and(self._alive_filter(), *or_filter)
193
+ if or_filter
194
+ else self._alive_filter()
177
195
  )
178
196
  cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
179
197
  if sort:
@@ -184,9 +202,11 @@ class NoSqlRepository:
184
202
  regex = {"$regex": q, "$options": "i"}
185
203
  or_filter = {"$or": [{f: regex} for f in fields]} if fields else {}
186
204
  filt = self._merge_and(self._alive_filter(), or_filter)
187
- return await db[self.collection_name].count_documents(filt or {})
205
+ return cast(int, await db[self.collection_name].count_documents(filt or {}))
188
206
 
189
207
  async def exists(self, db, *, where: Iterable[Dict[str, Any]]) -> bool:
190
208
  filt = self._merge_and(self._alive_filter(), *list(where))
191
- doc = await db[self.collection_name].find_one(filt, projection={self.id_field: 1})
209
+ doc = await db[self.collection_name].find_one(
210
+ filt, projection={self.id_field: 1}
211
+ )
192
212
  return doc is not None
@@ -1,9 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Any, Callable, Iterable, Optional, Sequence, Type, Union
5
-
6
- from pymongo import IndexModel
4
+ from typing import (
5
+ Any,
6
+ Callable,
7
+ Iterable,
8
+ Optional,
9
+ Sequence,
10
+ TYPE_CHECKING,
11
+ Type,
12
+ Union,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from pymongo import IndexModel
17
+ else:
18
+ try:
19
+ from pymongo import IndexModel
20
+ except ModuleNotFoundError:
21
+ # Minimal runtime stub so importing svc_infra works without optional Mongo deps.
22
+ class IndexModel: # type: ignore[no-redef]
23
+ pass
7
24
 
8
25
 
9
26
  def _snake(name: str) -> str:
@@ -6,7 +6,9 @@ from typing import Any, Dict, Literal, Optional
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
8
8
 
9
- _INIT_CONTENT_PAIRED = 'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
9
+ _INIT_CONTENT_PAIRED = (
10
+ 'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
11
+ )
10
12
  _INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
11
13
 
12
14
 
@@ -46,7 +48,9 @@ def scaffold_core(
46
48
  schemas_txt = render_template(
47
49
  tmpl_dir="svc_infra.db.nosql.mongo.templates",
48
50
  name="schemas.py.tmpl",
49
- subs={"Entity": ent}, # (only if your schemas.tmpl doesn't need collection_name)
51
+ subs={
52
+ "Entity": ent
53
+ }, # (only if your schemas.tmpl doesn't need collection_name)
50
54
  )
51
55
 
52
56
  if same_dir:
@@ -103,7 +107,9 @@ def scaffold_schemas_core(
103
107
  dest = normalize_dir(dest_dir)
104
108
  ent = pascal(entity_name)
105
109
  txt = render_template(
106
- tmpl_dir="svc_infra.db.nosql.mongo.templates", name="schemas.py.tmpl", subs={"Entity": ent}
110
+ tmpl_dir="svc_infra.db.nosql.mongo.templates",
111
+ name="schemas.py.tmpl",
112
+ subs={"Entity": ent},
107
113
  )
108
114
  filename = schemas_filename or f"{snake(entity_name)}.py"
109
115
  res = write(dest / filename, txt, overwrite)
@@ -43,7 +43,9 @@ class NoSqlService:
43
43
  async def search(
44
44
  self, db, *, q: str, fields: Sequence[str], limit: int, offset: int, sort=None
45
45
  ):
46
- return await self.repo.search(db, q=q, fields=fields, limit=limit, offset=offset, sort=sort)
46
+ return await self.repo.search(
47
+ db, q=q, fields=fields, limit=limit, offset=offset, sort=sort
48
+ )
47
49
 
48
50
  async def count_filtered(self, db, *, q: str, fields: Sequence[str]) -> int:
49
51
  return await self.repo.count_filtered(db, q=q, fields=fields)
@@ -11,7 +11,9 @@ class PyObjectId(ObjectId):
11
11
  """Pydantic v2-compatible ObjectId type."""
12
12
 
13
13
  @classmethod
14
- def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: GetCoreSchemaHandler):
14
+ def __get_pydantic_core_schema__(
15
+ cls, _source_type: Any, _handler: GetCoreSchemaHandler
16
+ ):
15
17
  def validate(v: Any) -> ObjectId:
16
18
  if isinstance(v, ObjectId):
17
19
  return v
@@ -22,4 +24,6 @@ class PyObjectId(ObjectId):
22
24
  raise ValueError(f"Invalid ObjectId: {v}") from e
23
25
  raise ValueError("ObjectId required")
24
26
 
25
- return core_schema.no_info_after_validator_function(validate, core_schema.any_schema())
27
+ return core_schema.no_info_after_validator_function(
28
+ validate, core_schema.any_schema()
29
+ )