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
@@ -0,0 +1,262 @@
1
+ """
2
+ Document storage operations using svc-infra storage backend.
3
+
4
+ This module provides CRUD operations for document management, storing file content
5
+ in the configured storage backend (S3, local, memory) and metadata in SQL.
6
+
7
+ Quick Start:
8
+ >>> import asyncio
9
+ >>> from svc_infra.storage import easy_storage
10
+ >>> from svc_infra.documents.storage import upload_document, get_document
11
+ >>>
12
+ >>> storage = easy_storage()
13
+ >>> doc = await upload_document(
14
+ ... storage=storage,
15
+ ... user_id="user_123",
16
+ ... file=b"file content",
17
+ ... filename="document.pdf",
18
+ ... metadata={"category": "legal"}
19
+ ... )
20
+ >>> print(doc.id, doc.storage_path)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import mimetypes
27
+ import uuid
28
+ from datetime import datetime
29
+ from typing import TYPE_CHECKING
30
+
31
+ if TYPE_CHECKING:
32
+ from svc_infra.storage.base import StorageBackend
33
+
34
+ from .models import Document
35
+
36
+ # In-memory metadata storage (production: use SQL database)
37
+ # This is a temporary solution until SQL integration is complete
38
+ _documents_metadata: dict[str, Document] = {}
39
+
40
+
41
+ async def upload_document(
42
+ storage: StorageBackend,
43
+ user_id: str,
44
+ file: bytes,
45
+ filename: str,
46
+ metadata: dict | None = None,
47
+ content_type: str | None = None,
48
+ ) -> Document:
49
+ """
50
+ Upload a document with file content to storage backend.
51
+
52
+ Args:
53
+ storage: Storage backend instance (S3, local, memory)
54
+ user_id: User uploading the document
55
+ file: File content as bytes
56
+ filename: Original filename
57
+ metadata: Optional custom metadata dictionary
58
+ content_type: Optional MIME type (auto-detected if not provided)
59
+
60
+ Returns:
61
+ Document with storage information and metadata
62
+
63
+ Examples:
64
+ >>> from svc_infra.storage import easy_storage
65
+ >>> storage = easy_storage()
66
+ >>>
67
+ >>> # Upload PDF document
68
+ >>> doc = upload_document(
69
+ ... storage=storage,
70
+ ... user_id="user_123",
71
+ ... file=pdf_bytes,
72
+ ... filename="contract.pdf",
73
+ ... metadata={"category": "legal", "year": 2024}
74
+ ... )
75
+ >>>
76
+ >>> # Upload image
77
+ >>> doc = upload_document(
78
+ ... storage=storage,
79
+ ... user_id="user_456",
80
+ ... file=image_bytes,
81
+ ... filename="photo.jpg",
82
+ ... content_type="image/jpeg"
83
+ ... )
84
+
85
+ Notes:
86
+ - Current: In-memory metadata storage (for development)
87
+ - Production: Store metadata in SQL database using svc-infra SQL helpers
88
+ - Storage path format: documents/{user_id}/{doc_id}/{filename}
89
+ - Checksum: SHA-256 hash for integrity validation
90
+ """
91
+ from .models import Document
92
+
93
+ # Generate unique document ID
94
+ doc_id = f"doc_{uuid.uuid4().hex[:12]}"
95
+
96
+ # Build storage path with user isolation
97
+ storage_path = f"documents/{user_id}/{doc_id}/{filename}"
98
+
99
+ # Detect content type if not provided
100
+ if content_type is None:
101
+ detected_type, _ = mimetypes.guess_type(filename)
102
+ content_type = detected_type or "application/octet-stream"
103
+
104
+ # Calculate checksum for integrity
105
+ checksum = f"sha256:{hashlib.sha256(file).hexdigest()}"
106
+
107
+ # Upload file to storage backend
108
+ await storage.put(storage_path, file, content_type=content_type, metadata=metadata or {})
109
+
110
+ # Create document metadata
111
+ doc = Document(
112
+ id=doc_id,
113
+ user_id=user_id,
114
+ filename=filename,
115
+ file_size=len(file),
116
+ upload_date=datetime.utcnow(),
117
+ storage_path=storage_path,
118
+ content_type=content_type,
119
+ checksum=checksum,
120
+ metadata=metadata or {},
121
+ )
122
+
123
+ # Store metadata (production: use SQL)
124
+ _documents_metadata[doc_id] = doc
125
+
126
+ return doc
127
+
128
+
129
+ def get_document(document_id: str) -> Document | None:
130
+ """
131
+ Get document metadata by ID.
132
+
133
+ Args:
134
+ document_id: Document identifier
135
+
136
+ Returns:
137
+ Document metadata or None if not found
138
+
139
+ Examples:
140
+ >>> doc = get_document("doc_abc123")
141
+ >>> if doc:
142
+ ... print(doc.filename, doc.file_size)
143
+ """
144
+ return _documents_metadata.get(document_id)
145
+
146
+
147
+ async def download_document(storage: StorageBackend, document_id: str) -> bytes:
148
+ """
149
+ Download document file content from storage.
150
+
151
+ Args:
152
+ storage: Storage backend instance
153
+ document_id: Document identifier
154
+
155
+ Returns:
156
+ Document file content as bytes
157
+
158
+ Raises:
159
+ ValueError: If document not found
160
+
161
+ Examples:
162
+ >>> from svc_infra.storage import easy_storage
163
+ >>> storage = easy_storage()
164
+ >>>
165
+ >>> file_data = await download_document(storage, "doc_abc123")
166
+ >>> with open("downloaded.pdf", "wb") as f:
167
+ ... f.write(file_data)
168
+ """
169
+ doc = get_document(document_id)
170
+ if not doc:
171
+ raise ValueError(f"Document not found: {document_id}")
172
+
173
+ # Download from storage backend
174
+ return await storage.get(doc.storage_path)
175
+
176
+
177
+ async def delete_document(storage: StorageBackend, document_id: str) -> bool:
178
+ """
179
+ Delete document and its file content.
180
+
181
+ Args:
182
+ storage: Storage backend instance
183
+ document_id: Document identifier
184
+
185
+ Returns:
186
+ True if deleted, False if not found
187
+
188
+ Examples:
189
+ >>> from svc_infra.storage import easy_storage
190
+ >>> storage = easy_storage()
191
+ >>>
192
+ >>> success = delete_document(storage, "doc_abc123")
193
+ >>> if success:
194
+ ... print("Document deleted")
195
+ """
196
+ doc = get_document(document_id)
197
+ if not doc:
198
+ return False
199
+
200
+ # Delete from storage backend
201
+ await storage.delete(doc.storage_path)
202
+
203
+ # Delete metadata (production: use SQL)
204
+ del _documents_metadata[document_id]
205
+
206
+ return True
207
+
208
+
209
+ def list_documents(
210
+ user_id: str,
211
+ limit: int = 100,
212
+ offset: int = 0,
213
+ ) -> list[Document]:
214
+ """
215
+ List user's documents with pagination.
216
+
217
+ Args:
218
+ user_id: User identifier
219
+ limit: Maximum number of documents to return
220
+ offset: Number of documents to skip
221
+
222
+ Returns:
223
+ List of user's documents
224
+
225
+ Examples:
226
+ >>> # Get first page
227
+ >>> docs = list_documents("user_123", limit=20)
228
+ >>>
229
+ >>> # Get second page
230
+ >>> docs = list_documents("user_123", limit=20, offset=20)
231
+ >>>
232
+ >>> # Filter by metadata (future enhancement)
233
+ >>> # docs = list_documents("user_123", filters={"category": "legal"})
234
+
235
+ Notes:
236
+ - Current: In-memory filtering
237
+ - Production: Use SQL queries with proper indexing
238
+ - Future: Add metadata filtering and sorting
239
+ """
240
+ # Filter by user (production: SQL query)
241
+ user_docs = [doc for doc in _documents_metadata.values() if doc.user_id == user_id]
242
+
243
+ # Sort by upload date (newest first)
244
+ user_docs.sort(key=lambda d: d.upload_date, reverse=True)
245
+
246
+ # Apply pagination
247
+ return user_docs[offset : offset + limit]
248
+
249
+
250
+ def clear_storage() -> None:
251
+ """
252
+ Clear all document metadata (for testing only).
253
+
254
+ Warning:
255
+ This does NOT delete files from storage backend.
256
+ Only use in test environments.
257
+
258
+ Examples:
259
+ >>> # In tests
260
+ >>> clear_storage()
261
+ """
262
+ _documents_metadata.clear()
@@ -0,0 +1,58 @@
1
+ """Developer experience utilities for CI, changelog, and code quality checks.
2
+
3
+ This module provides utilities to improve developer experience:
4
+
5
+ - **CI Workflow**: Generate GitHub Actions CI workflow files
6
+ - **Changelog**: Generate release sections from conventional commits
7
+ - **Checks**: OpenAPI schema validation and migration verification
8
+
9
+ Example:
10
+ from svc_infra.dx import write_ci_workflow, write_openapi_lint_config
11
+
12
+ # Generate CI workflow for a project
13
+ write_ci_workflow(target_dir="./myproject", python_version="3.12")
14
+
15
+ # Generate OpenAPI lint config
16
+ write_openapi_lint_config(target_dir="./myproject")
17
+
18
+ # Validate OpenAPI schema
19
+ from svc_infra.dx import check_openapi_problem_schema
20
+
21
+ check_openapi_problem_schema(path="openapi.json")
22
+
23
+ # Generate changelog section
24
+ from svc_infra.dx import Commit, generate_release_section
25
+
26
+ commits = [
27
+ Commit(sha="abc123", subject="feat: add new feature"),
28
+ Commit(sha="def456", subject="fix: resolve bug"),
29
+ ]
30
+ changelog = generate_release_section(version="1.0.0", commits=commits)
31
+ print(changelog)
32
+
33
+ See Also:
34
+ - CLI commands: svc-infra dx openapi, svc-infra dx changelog
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ # CI workflow generation
40
+ from .add import write_ci_workflow, write_openapi_lint_config
41
+
42
+ # Changelog generation
43
+ from .changelog import Commit, generate_release_section
44
+
45
+ # Code quality checks
46
+ from .checks import check_migrations_up_to_date, check_openapi_problem_schema
47
+
48
+ __all__ = [
49
+ # CI workflow
50
+ "write_ci_workflow",
51
+ "write_openapi_lint_config",
52
+ # Changelog
53
+ "Commit",
54
+ "generate_release_section",
55
+ # Checks
56
+ "check_openapi_problem_schema",
57
+ "check_migrations_up_to_date",
58
+ ]
svc_infra/dx/add.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def write_ci_workflow(
7
+ *,
8
+ target_dir: str | Path,
9
+ name: str = "ci.yml",
10
+ python_version: str = "3.12",
11
+ ) -> Path:
12
+ """Write a minimal CI workflow file (GitHub Actions) with tests/lint/type steps."""
13
+ p = Path(target_dir) / ".github" / "workflows" / name
14
+ p.parent.mkdir(parents=True, exist_ok=True)
15
+ content = f"""
16
+ name: CI
17
+
18
+ on:
19
+ push:
20
+ branches: [ main ]
21
+ pull_request:
22
+
23
+ jobs:
24
+ build:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: '{python_version}'
31
+ - name: Install Poetry
32
+ run: pipx install poetry
33
+ - name: Install deps
34
+ run: poetry install
35
+ - name: Lint
36
+ run: poetry run flake8 --select=E,F
37
+ - name: Typecheck
38
+ run: poetry run mypy src
39
+ - name: Tests
40
+ run: poetry run pytest -q -W error
41
+ """
42
+ p.write_text(content.strip() + "\n")
43
+ return p
44
+
45
+
46
+ def write_openapi_lint_config(*, target_dir: str | Path, name: str = ".redocly.yaml") -> Path:
47
+ """Write a minimal OpenAPI lint config placeholder (Redocly)."""
48
+ p = Path(target_dir) / name
49
+ content = """
50
+ apis:
51
+ main:
52
+ root: openapi.json
53
+
54
+ rules:
55
+ operation-operationId: warn
56
+ no-unused-components: warn
57
+ security-defined: off
58
+ """
59
+ p.write_text(content.strip() + "\n")
60
+ return p
61
+
62
+
63
+ __all__ = ["write_ci_workflow", "write_openapi_lint_config"]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from datetime import date as _date
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Commit:
10
+ sha: str
11
+ subject: str
12
+
13
+
14
+ _SECTION_ORDER = [
15
+ ("feat", "Features"),
16
+ ("fix", "Bug Fixes"),
17
+ ("perf", "Performance"),
18
+ ("refactor", "Refactors"),
19
+ ]
20
+
21
+
22
+ def _classify(subject: str) -> tuple[str, str]:
23
+ """Return (type, title) where title is display name of the section."""
24
+ lower = subject.strip().lower()
25
+ for t, title in _SECTION_ORDER:
26
+ if lower.startswith(t + ":") or lower.startswith(t + "("):
27
+ return (t, title)
28
+ return ("other", "Other")
29
+
30
+
31
+ def _format_item(commit: Commit) -> str:
32
+ subj = commit.subject.strip()
33
+ # Strip leading type(scope): if present
34
+ i = subj.find(": ")
35
+ if i != -1 and i < 20: # conventional commit prefix
36
+ pretty = subj[i + 2 :].strip()
37
+ else:
38
+ pretty = subj
39
+ return f"- {pretty} ({commit.sha})"
40
+
41
+
42
+ def generate_release_section(
43
+ *,
44
+ version: str,
45
+ commits: Sequence[Commit],
46
+ release_date: str | None = None,
47
+ ) -> str:
48
+ """Generate a markdown release section from commits.
49
+
50
+ Group by type: feat, fix, perf, refactor; everything else under Other.
51
+ """
52
+ if release_date is None:
53
+ release_date = _date.today().isoformat()
54
+
55
+ buckets: dict[str, list[str]] = {k: [] for k, _ in _SECTION_ORDER}
56
+ buckets["other"] = []
57
+
58
+ for c in commits:
59
+ typ, _ = _classify(c.subject)
60
+ buckets.setdefault(typ, []).append(_format_item(c))
61
+
62
+ lines: list[str] = [f"## v{version} - {release_date}", ""]
63
+ for key, title in [*_SECTION_ORDER, ("other", "Other")]:
64
+ items = buckets.get(key) or []
65
+ if not items:
66
+ continue
67
+ lines.append(f"### {title}")
68
+ lines.extend(items)
69
+ lines.append("")
70
+
71
+ return "\n".join(lines).rstrip() + "\n"
72
+
73
+
74
+ __all__ = ["Commit", "generate_release_section"]
svc_infra/dx/checks.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, cast
5
+
6
+
7
+ def _load_json(path: str | Path) -> dict[Any, Any]:
8
+ import json
9
+
10
+ p = Path(path)
11
+ return cast("dict[Any, Any]", json.loads(p.read_text()))
12
+
13
+
14
+ def check_openapi_problem_schema(
15
+ schema: dict | None = None, *, path: str | Path | None = None
16
+ ) -> None:
17
+ """Validate OpenAPI has a Problem schema with required fields and formats.
18
+
19
+ Raises ValueError with a descriptive message on failure.
20
+ """
21
+
22
+ if schema is None:
23
+ if path is None:
24
+ raise ValueError("either schema or path must be provided")
25
+ schema = _load_json(path)
26
+
27
+ comps = (schema or {}).get("components") or {}
28
+ prob = (comps.get("schemas") or {}).get("Problem")
29
+ if not isinstance(prob, dict):
30
+ raise ValueError("Problem schema missing under components.schemas.Problem")
31
+
32
+ props = prob.get("properties") or {}
33
+ # Required keys presence
34
+ for key in ("type", "title", "status", "detail", "instance", "code"):
35
+ if key not in props:
36
+ raise ValueError(f"Problem.{key} missing in properties")
37
+
38
+ # instance must be uri-reference per our convention
39
+ inst = props.get("instance") or {}
40
+ if inst.get("format") != "uri-reference":
41
+ raise ValueError("Problem.instance must have format 'uri-reference'")
42
+
43
+
44
+ def check_migrations_up_to_date(*, project_root: str | Path = ".") -> None:
45
+ """Best-effort migrations check: passes if alembic env present and head is reachable.
46
+
47
+ This is a lightweight stub that can be extended per-project. For now, it checks
48
+ that an Alembic env exists when 'alembic.ini' is present; it does not execute DB calls.
49
+ """
50
+
51
+ root = Path(project_root)
52
+ # If alembic.ini is absent, there's nothing to check here
53
+ if not (root / "alembic.ini").exists():
54
+ return
55
+ # Ensure versions/ dir exists under migrations path if configured, default to 'migrations'
56
+ mig_dir = root / "migrations"
57
+ if not mig_dir.exists():
58
+ # tolerate alternative layout via env; keep stub permissive
59
+ return
60
+ versions = mig_dir / "versions"
61
+ if not versions.exists():
62
+ raise ValueError("Alembic migrations directory missing versions/ subfolder")
63
+
64
+
65
+ __all__ = [
66
+ "check_openapi_problem_schema",
67
+ "check_migrations_up_to_date",
68
+ ]
@@ -0,0 +1,141 @@
1
+ """Centralized exception re-exports for svc-infra.
2
+
3
+ This module provides a single import point for all svc-infra exceptions.
4
+ Exceptions are organized by domain and all inherit from the base SvcInfraError.
5
+
6
+ Example:
7
+ from svc_infra.exceptions import (
8
+ SvcInfraError,
9
+ WebSocketError,
10
+ StorageError,
11
+ FastApiException,
12
+ )
13
+ """
14
+
15
+ # ruff: noqa: E402
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any
21
+
22
+ # =============================================================================
23
+ # Logging Helper
24
+ # =============================================================================
25
+
26
+
27
+ def log_exception(
28
+ logger: logging.Logger,
29
+ msg: str,
30
+ exc: Exception,
31
+ *,
32
+ level: str = "warning",
33
+ include_traceback: bool = True,
34
+ ) -> None:
35
+ """Log an exception with consistent formatting.
36
+
37
+ Use this helper instead of bare `except Exception:` blocks to ensure
38
+ all exceptions are properly logged with context.
39
+
40
+ Args:
41
+ logger: The logger instance to use
42
+ msg: Context message describing what operation failed
43
+ exc: The exception that was caught
44
+ level: Log level - "debug", "info", "warning", "error", "critical"
45
+ include_traceback: Whether to include full traceback (exc_info=True)
46
+
47
+ Example:
48
+ try:
49
+ result = await service.process()
50
+ except Exception as e:
51
+ log_exception(logger, "Failed to process request", e)
52
+ # Handle gracefully or re-raise
53
+ """
54
+ log_func = getattr(logger, level.lower(), logger.warning)
55
+ log_func(f"{msg}: {type(exc).__name__}: {exc}", exc_info=include_traceback)
56
+
57
+
58
+ # =============================================================================
59
+ # Base Error
60
+ # =============================================================================
61
+
62
+
63
+ class SvcInfraError(Exception):
64
+ """Base exception for all svc-infra errors.
65
+
66
+ All svc-infra exceptions can be caught with this single class.
67
+
68
+ Attributes:
69
+ message: Human-readable error description
70
+ details: Additional context as key-value pairs
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ message: str,
76
+ *,
77
+ details: dict[str, Any] | None = None,
78
+ ):
79
+ self.message = message
80
+ self.details = details or {}
81
+ super().__init__(message)
82
+
83
+ def __repr__(self) -> str:
84
+ return f"{self.__class__.__name__}({self.message!r})"
85
+
86
+
87
+ # =============================================================================
88
+ # Re-exports from submodules
89
+ # =============================================================================
90
+
91
+ # API exceptions
92
+ from svc_infra.api.fastapi.middleware.errors.exceptions import FastApiException
93
+
94
+ # App exceptions
95
+ from svc_infra.app.env import MissingSecretError
96
+
97
+ # Security exceptions
98
+ from svc_infra.security.passwords import PasswordValidationError
99
+
100
+ # Storage exceptions
101
+ from svc_infra.storage.base import FileNotFoundError as StorageFileNotFoundError
102
+ from svc_infra.storage.base import (
103
+ InvalidKeyError,
104
+ PermissionDeniedError,
105
+ QuotaExceededError,
106
+ StorageError,
107
+ )
108
+
109
+ # WebSocket exceptions
110
+ from svc_infra.websocket.exceptions import AuthenticationError as WebSocketAuthError
111
+ from svc_infra.websocket.exceptions import (
112
+ ConnectionClosedError,
113
+ ConnectionFailedError,
114
+ MessageTooLargeError,
115
+ WebSocketError,
116
+ )
117
+
118
+ __all__ = [
119
+ # Logging helper
120
+ "log_exception",
121
+ # Base
122
+ "SvcInfraError",
123
+ # WebSocket
124
+ "WebSocketError",
125
+ "WebSocketAuthError",
126
+ "ConnectionClosedError",
127
+ "ConnectionFailedError",
128
+ "MessageTooLargeError",
129
+ # Storage
130
+ "StorageError",
131
+ "StorageFileNotFoundError",
132
+ "InvalidKeyError",
133
+ "PermissionDeniedError",
134
+ "QuotaExceededError",
135
+ # API
136
+ "FastApiException",
137
+ # App
138
+ "MissingSecretError",
139
+ # Security
140
+ "PasswordValidationError",
141
+ ]