svc-infra 0.1.706__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 (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
svc_infra/storage/easy.py CHANGED
@@ -6,7 +6,6 @@ Simplifies storage backend initialization with sensible defaults.
6
6
 
7
7
  import logging
8
8
  import os
9
- from typing import Optional
10
9
 
11
10
  from .backends import LocalBackend, MemoryBackend, S3Backend
12
11
  from .base import StorageBackend
@@ -16,7 +15,7 @@ logger = logging.getLogger(__name__)
16
15
 
17
16
 
18
17
  def easy_storage(
19
- backend: Optional[str] = None,
18
+ backend: str | None = None,
20
19
  **kwargs,
21
20
  ) -> StorageBackend:
22
21
  """
@@ -135,9 +134,7 @@ def easy_storage(
135
134
  secret_key = kwargs.get("secret_key")
136
135
 
137
136
  if not access_key or not secret_key:
138
- access_key_from_settings, secret_key_from_settings = (
139
- settings.get_s3_credentials()
140
- )
137
+ access_key_from_settings, secret_key_from_settings = settings.get_s3_credentials()
141
138
  access_key = access_key or access_key_from_settings
142
139
  secret_key = secret_key or secret_key_from_settings
143
140
 
@@ -165,14 +162,13 @@ def easy_storage(
165
162
  elif backend_type == "gcs":
166
163
  # Google Cloud Storage backend
167
164
  raise NotImplementedError(
168
- "GCS backend not yet implemented. " "Use 'local' or 's3' backend for now."
165
+ "GCS backend not yet implemented. Use 'local' or 's3' backend for now."
169
166
  )
170
167
 
171
168
  elif backend_type == "cloudinary":
172
169
  # Cloudinary backend
173
170
  raise NotImplementedError(
174
- "Cloudinary backend not yet implemented. "
175
- "Use 'local' or 's3' backend for now."
171
+ "Cloudinary backend not yet implemented. Use 'local' or 's3' backend for now."
176
172
  )
177
173
 
178
174
  else:
@@ -5,7 +5,7 @@ Handles environment-based configuration and auto-detection of storage backends.
5
5
  """
6
6
 
7
7
  import os
8
- from typing import Literal, Optional
8
+ from typing import Literal
9
9
 
10
10
  from pydantic import Field
11
11
  from pydantic_settings import BaseSettings
@@ -52,11 +52,9 @@ class StorageSettings(BaseSettings):
52
52
  """
53
53
 
54
54
  # Backend selection
55
- storage_backend: Optional[Literal["local", "s3", "gcs", "cloudinary", "memory"]] = (
56
- Field(
57
- default=None,
58
- description="Storage backend type (auto-detected if not set)",
59
- )
55
+ storage_backend: Literal["local", "s3", "gcs", "cloudinary", "memory"] | None = Field(
56
+ default=None,
57
+ description="Storage backend type (auto-detected if not set)",
60
58
  )
61
59
 
62
60
  # Local backend settings
@@ -68,13 +66,13 @@ class StorageSettings(BaseSettings):
68
66
  default="http://localhost:8000/files",
69
67
  description="Base URL for serving files",
70
68
  )
71
- storage_signing_secret: Optional[str] = Field(
69
+ storage_signing_secret: str | None = Field(
72
70
  default=None,
73
71
  description="Secret key for URL signing (auto-generated if not set)",
74
72
  )
75
73
 
76
74
  # S3 backend settings
77
- storage_s3_bucket: Optional[str] = Field(
75
+ storage_s3_bucket: str | None = Field(
78
76
  default=None,
79
77
  description="S3 bucket name",
80
78
  )
@@ -82,43 +80,43 @@ class StorageSettings(BaseSettings):
82
80
  default="us-east-1",
83
81
  description="AWS region",
84
82
  )
85
- storage_s3_endpoint: Optional[str] = Field(
83
+ storage_s3_endpoint: str | None = Field(
86
84
  default=None,
87
85
  description="Custom S3 endpoint (for DigitalOcean Spaces, Wasabi, etc.)",
88
86
  )
89
- storage_s3_access_key: Optional[str] = Field(
87
+ storage_s3_access_key: str | None = Field(
90
88
  default=None,
91
89
  description="S3 access key (falls back to AWS_ACCESS_KEY_ID)",
92
90
  )
93
- storage_s3_secret_key: Optional[str] = Field(
91
+ storage_s3_secret_key: str | None = Field(
94
92
  default=None,
95
93
  description="S3 secret key (falls back to AWS_SECRET_ACCESS_KEY)",
96
94
  )
97
95
 
98
96
  # GCS backend settings
99
- storage_gcs_bucket: Optional[str] = Field(
97
+ storage_gcs_bucket: str | None = Field(
100
98
  default=None,
101
99
  description="Google Cloud Storage bucket name",
102
100
  )
103
- storage_gcs_project: Optional[str] = Field(
101
+ storage_gcs_project: str | None = Field(
104
102
  default=None,
105
103
  description="GCP project ID",
106
104
  )
107
- storage_gcs_credentials_path: Optional[str] = Field(
105
+ storage_gcs_credentials_path: str | None = Field(
108
106
  default=None,
109
107
  description="Path to GCP service account JSON",
110
108
  )
111
109
 
112
110
  # Cloudinary backend settings
113
- storage_cloudinary_cloud_name: Optional[str] = Field(
111
+ storage_cloudinary_cloud_name: str | None = Field(
114
112
  default=None,
115
113
  description="Cloudinary cloud name",
116
114
  )
117
- storage_cloudinary_api_key: Optional[str] = Field(
115
+ storage_cloudinary_api_key: str | None = Field(
118
116
  default=None,
119
117
  description="Cloudinary API key",
120
118
  )
121
- storage_cloudinary_api_secret: Optional[str] = Field(
119
+ storage_cloudinary_api_secret: str | None = Field(
122
120
  default=None,
123
121
  description="Cloudinary API secret",
124
122
  )
@@ -176,7 +174,7 @@ class StorageSettings(BaseSettings):
176
174
  # Default to memory (for development/testing)
177
175
  return "memory"
178
176
 
179
- def get_s3_credentials(self) -> tuple[Optional[str], Optional[str]]:
177
+ def get_s3_credentials(self) -> tuple[str | None, str | None]:
180
178
  """
181
179
  Get S3 credentials with fallback to AWS environment variables.
182
180
 
@@ -28,9 +28,10 @@ from __future__ import annotations
28
28
 
29
29
  import time
30
30
  import uuid
31
+ from collections.abc import Callable
31
32
  from dataclasses import dataclass, field
32
- from datetime import datetime, timedelta, timezone
33
- from typing import Any, Callable, Dict, List, Optional, TypeVar
33
+ from datetime import UTC, datetime, timedelta
34
+ from typing import Any, TypeVar
34
35
 
35
36
  # Type variable for generic model creation
36
37
  T = TypeVar("T")
@@ -46,7 +47,7 @@ class CacheEntry:
46
47
  """Internal representation of a cached value."""
47
48
 
48
49
  value: Any
49
- expires_at: Optional[float] = None # Unix timestamp
50
+ expires_at: float | None = None # Unix timestamp
50
51
 
51
52
  def is_expired(self) -> bool:
52
53
  """Check if this entry has expired."""
@@ -86,14 +87,14 @@ class MockCache:
86
87
  prefix: Key prefix for namespacing (default: "test")
87
88
  """
88
89
  self.prefix = prefix
89
- self._store: Dict[str, CacheEntry] = {}
90
- self._tags: Dict[str, set[str]] = {} # tag -> set of keys
90
+ self._store: dict[str, CacheEntry] = {}
91
+ self._tags: dict[str, set[str]] = {} # tag -> set of keys
91
92
 
92
93
  def _prefixed_key(self, key: str) -> str:
93
94
  """Get the full key with prefix."""
94
95
  return f"{self.prefix}:{key}"
95
96
 
96
- def get(self, key: str) -> Optional[Any]:
97
+ def get(self, key: str) -> Any | None:
97
98
  """
98
99
  Get a value from the cache.
99
100
 
@@ -116,8 +117,8 @@ class MockCache:
116
117
  self,
117
118
  key: str,
118
119
  value: Any,
119
- ttl: Optional[int] = None,
120
- tags: Optional[List[str]] = None,
120
+ ttl: int | None = None,
121
+ tags: list[str] | None = None,
121
122
  ) -> None:
122
123
  """
123
124
  Set a value in the cache.
@@ -208,7 +209,7 @@ class MockCache:
208
209
  self._store.clear()
209
210
  self._tags.clear()
210
211
 
211
- def keys(self, pattern: str = "*") -> List[str]:
212
+ def keys(self, pattern: str = "*") -> list[str]:
212
213
  """
213
214
  Get all keys matching a pattern.
214
215
 
@@ -233,9 +234,7 @@ class MockCache:
233
234
  # Clean up expired entries
234
235
  now = time.time()
235
236
  self._store = {
236
- k: v
237
- for k, v in self._store.items()
238
- if v.expires_at is None or v.expires_at > now
237
+ k: v for k, v in self._store.items() if v.expires_at is None or v.expires_at > now
239
238
  }
240
239
  return len(self._store)
241
240
 
@@ -251,14 +250,14 @@ class MockJob:
251
250
 
252
251
  id: str
253
252
  name: str
254
- payload: Dict[str, Any]
255
- created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
256
- available_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
253
+ payload: dict[str, Any]
254
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
255
+ available_at: datetime = field(default_factory=lambda: datetime.now(UTC))
257
256
  attempts: int = 0
258
257
  max_attempts: int = 5
259
258
  status: str = "pending" # pending, processing, completed, failed
260
- result: Optional[Any] = None
261
- error: Optional[str] = None
259
+ result: Any | None = None
260
+ error: str | None = None
262
261
 
263
262
 
264
263
  class MockJobQueue:
@@ -294,10 +293,10 @@ class MockJobQueue:
294
293
  """
295
294
  self.sync_mode = sync_mode
296
295
  self._seq = 0
297
- self._jobs: List[MockJob] = []
298
- self._handlers: Dict[str, Callable[[Dict[str, Any]], Any]] = {}
299
- self._completed: List[MockJob] = []
300
- self._failed: List[MockJob] = []
296
+ self._jobs: list[MockJob] = []
297
+ self._handlers: dict[str, Callable[[dict[str, Any]], Any]] = {}
298
+ self._completed: list[MockJob] = []
299
+ self._failed: list[MockJob] = []
301
300
 
302
301
  def _next_id(self) -> str:
303
302
  """Generate next job ID."""
@@ -315,15 +314,13 @@ class MockJobQueue:
315
314
  Decorator function
316
315
  """
317
316
 
318
- def decorator(func: Callable[[Dict[str, Any]], Any]) -> Callable:
317
+ def decorator(func: Callable[[dict[str, Any]], Any]) -> Callable:
319
318
  self._handlers[name] = func
320
319
  return func
321
320
 
322
321
  return decorator
323
322
 
324
- def register_handler(
325
- self, name: str, handler: Callable[[Dict[str, Any]], Any]
326
- ) -> None:
323
+ def register_handler(self, name: str, handler: Callable[[dict[str, Any]], Any]) -> None:
327
324
  """
328
325
  Register a job handler function.
329
326
 
@@ -336,7 +333,7 @@ class MockJobQueue:
336
333
  def enqueue(
337
334
  self,
338
335
  name: str,
339
- payload: Dict[str, Any],
336
+ payload: dict[str, Any],
340
337
  *,
341
338
  delay_seconds: int = 0,
342
339
  max_attempts: int = 5,
@@ -353,7 +350,7 @@ class MockJobQueue:
353
350
  Returns:
354
351
  The created MockJob
355
352
  """
356
- available_at = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
353
+ available_at = datetime.now(UTC) + timedelta(seconds=delay_seconds)
357
354
  job = MockJob(
358
355
  id=self._next_id(),
359
356
  name=name,
@@ -400,17 +397,17 @@ class MockJobQueue:
400
397
  job.status = "pending"
401
398
  # Exponential backoff
402
399
  delay = 60 * job.attempts
403
- job.available_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
400
+ job.available_at = datetime.now(UTC) + timedelta(seconds=delay)
404
401
  return False
405
402
 
406
- def process_next(self) -> Optional[MockJob]:
403
+ def process_next(self) -> MockJob | None:
407
404
  """
408
405
  Process the next available job.
409
406
 
410
407
  Returns:
411
408
  The processed job, or None if no jobs available
412
409
  """
413
- now = datetime.now(timezone.utc)
410
+ now = datetime.now(UTC)
414
411
  for job in self._jobs:
415
412
  if job.status == "pending" and job.available_at <= now:
416
413
  self._process_job(job)
@@ -432,17 +429,17 @@ class MockJobQueue:
432
429
  return count
433
430
 
434
431
  @property
435
- def jobs(self) -> List[MockJob]:
432
+ def jobs(self) -> list[MockJob]:
436
433
  """Get all pending jobs."""
437
434
  return [j for j in self._jobs if j.status == "pending"]
438
435
 
439
436
  @property
440
- def completed_jobs(self) -> List[MockJob]:
437
+ def completed_jobs(self) -> list[MockJob]:
441
438
  """Get all completed jobs."""
442
439
  return self._completed.copy()
443
440
 
444
441
  @property
445
- def failed_jobs(self) -> List[MockJob]:
442
+ def failed_jobs(self) -> list[MockJob]:
446
443
  """Get all failed jobs."""
447
444
  return self._failed.copy()
448
445
 
@@ -452,7 +449,7 @@ class MockJobQueue:
452
449
  self._completed.clear()
453
450
  self._failed.clear()
454
451
 
455
- def get_job(self, job_id: str) -> Optional[MockJob]:
452
+ def get_job(self, job_id: str) -> MockJob | None:
456
453
  """
457
454
  Get a job by ID.
458
455
 
@@ -493,8 +490,8 @@ class UserFixtureData:
493
490
  is_active: bool = True
494
491
  is_verified: bool = True
495
492
  is_superuser: bool = False
496
- full_name: Optional[str] = None
497
- extra: Dict[str, Any] = field(default_factory=dict)
493
+ full_name: str | None = None
494
+ extra: dict[str, Any] = field(default_factory=dict)
498
495
 
499
496
 
500
497
  @dataclass
@@ -503,9 +500,9 @@ class TenantFixtureData:
503
500
 
504
501
  id: str = field(default_factory=generate_uuid)
505
502
  name: str = field(default_factory=lambda: f"Test Tenant {uuid.uuid4().hex[:6]}")
506
- slug: Optional[str] = None
503
+ slug: str | None = None
507
504
  is_active: bool = True
508
- extra: Dict[str, Any] = field(default_factory=dict)
505
+ extra: dict[str, Any] = field(default_factory=dict)
509
506
 
510
507
  def __post_init__(self):
511
508
  if self.slug is None:
@@ -629,7 +626,7 @@ async def create_test_tenant(
629
626
  # =============================================================================
630
627
 
631
628
 
632
- def pytest_fixtures() -> Dict[str, Callable]:
629
+ def pytest_fixtures() -> dict[str, Callable]:
633
630
  """
634
631
  Get pytest fixture functions for use in conftest.py.
635
632
 
svc_infra/utils.py CHANGED
@@ -1,17 +1,43 @@
1
+ """svc-infra utilities module.
2
+
3
+ This module provides utility functions and helpers for svc-infra, including:
4
+ - Template rendering and file writing utilities
5
+ - Deprecation decorators and warnings
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
1
11
  import importlib.resources as pkg
12
+ import warnings
13
+ from collections.abc import Callable
2
14
  from pathlib import Path
3
15
  from string import Template as _T
4
- from typing import Any, Dict
16
+ from typing import Any, TypeVar
17
+
18
+ __all__ = [
19
+ # Template utilities
20
+ "render_template",
21
+ "write",
22
+ "ensure_init_py",
23
+ # Deprecation utilities
24
+ "deprecated",
25
+ "deprecated_parameter",
26
+ "DeprecatedWarning",
27
+ ]
28
+
5
29
 
30
+ # =============================================================================
31
+ # Template Utilities
32
+ # =============================================================================
6
33
 
7
- def render_template(
8
- tmpl_dir: str, name: str, subs: dict[str, Any] | None = None
9
- ) -> str:
34
+
35
+ def render_template(tmpl_dir: str, name: str, subs: dict[str, Any] | None = None) -> str:
10
36
  txt = pkg.files(tmpl_dir).joinpath(name).read_text(encoding="utf-8")
11
37
  return _T(txt).safe_substitute(subs or {})
12
38
 
13
39
 
14
- def write(dest: Path, content: str, overwrite: bool = False) -> Dict[str, Any]:
40
+ def write(dest: Path, content: str, overwrite: bool = False) -> dict[str, Any]:
15
41
  dest = dest.resolve()
16
42
  dest.parent.mkdir(parents=True, exist_ok=True)
17
43
  if dest.exists() and not overwrite:
@@ -20,8 +46,143 @@ def write(dest: Path, content: str, overwrite: bool = False) -> Dict[str, Any]:
20
46
  return {"path": str(dest), "action": "wrote"}
21
47
 
22
48
 
23
- def ensure_init_py(
24
- dir_path: Path, overwrite: bool, paired: bool, content: str
25
- ) -> Dict[str, Any]:
49
+ def ensure_init_py(dir_path: Path, overwrite: bool, paired: bool, content: str) -> dict[str, Any]:
26
50
  """Create __init__.py; paired=True writes models/schemas re-exports, otherwise minimal."""
27
51
  return write(dir_path / "__init__.py", content, overwrite)
52
+
53
+
54
+ # =============================================================================
55
+ # Deprecation Utilities
56
+ # =============================================================================
57
+
58
+ F = TypeVar("F", bound=Callable[..., Any])
59
+
60
+
61
+ class DeprecatedWarning(DeprecationWarning):
62
+ """Custom deprecation warning for svc-infra.
63
+
64
+ This warning is used to distinguish svc-infra deprecations from
65
+ Python's built-in DeprecationWarning.
66
+ """
67
+
68
+ pass
69
+
70
+
71
+ def deprecated(
72
+ version: str,
73
+ reason: str,
74
+ removal_version: str | None = None,
75
+ *,
76
+ stacklevel: int = 2,
77
+ ) -> Callable[[F], F]:
78
+ """Decorator to mark a function or class as deprecated.
79
+
80
+ The decorated function/class will emit a DeprecationWarning when called/instantiated.
81
+
82
+ Args:
83
+ version: The version in which the feature was deprecated (e.g., "1.2.0").
84
+ reason: The reason for deprecation and recommended alternative.
85
+ removal_version: The version in which the feature will be removed (e.g., "1.4.0").
86
+ stacklevel: Stack level for the warning (default 2 for immediate caller).
87
+
88
+ Returns:
89
+ A decorator that wraps the function/class with deprecation warning.
90
+
91
+ Example:
92
+ >>> @deprecated(
93
+ ... version="1.2.0",
94
+ ... reason="Use new_function() instead",
95
+ ... removal_version="1.4.0"
96
+ ... )
97
+ ... def old_function():
98
+ ... return "result"
99
+ >>>
100
+ >>> old_function() # Emits DeprecationWarning
101
+ 'result'
102
+ """
103
+
104
+ def decorator(func: F) -> F:
105
+ # Build the warning message
106
+ name = getattr(func, "__qualname__", getattr(func, "__name__", str(func)))
107
+ message = f"{name} is deprecated since version {version}."
108
+
109
+ if removal_version:
110
+ message += f" It will be removed in version {removal_version}."
111
+
112
+ message += f" {reason}"
113
+
114
+ if isinstance(func, type):
115
+ # Handle class deprecation
116
+ original_init = func.__init__ # type: ignore[misc]
117
+
118
+ @functools.wraps(original_init)
119
+ def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
120
+ warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel)
121
+ original_init(self, *args, **kwargs)
122
+
123
+ func.__init__ = new_init # type: ignore[misc]
124
+
125
+ # Add deprecation info to docstring
126
+ if func.__doc__:
127
+ func.__doc__ = f".. deprecated:: {version}\n {reason}\n\n{func.__doc__}"
128
+ else:
129
+ func.__doc__ = f".. deprecated:: {version}\n {reason}"
130
+
131
+ return func # type: ignore[return-value]
132
+ else:
133
+ # Handle function deprecation
134
+ @functools.wraps(func)
135
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
136
+ warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel)
137
+ return func(*args, **kwargs)
138
+
139
+ # Add deprecation info to docstring
140
+ if wrapper.__doc__:
141
+ wrapper.__doc__ = f".. deprecated:: {version}\n {reason}\n\n{wrapper.__doc__}"
142
+ else:
143
+ wrapper.__doc__ = f".. deprecated:: {version}\n {reason}"
144
+
145
+ return wrapper # type: ignore[return-value]
146
+
147
+ return decorator
148
+
149
+
150
+ def deprecated_parameter(
151
+ name: str,
152
+ version: str,
153
+ reason: str,
154
+ removal_version: str | None = None,
155
+ *,
156
+ stacklevel: int = 2,
157
+ ) -> None:
158
+ """Emit a deprecation warning for a deprecated parameter.
159
+
160
+ Call this function when a deprecated parameter is used. This should be
161
+ called at the beginning of a function that has deprecated parameters.
162
+
163
+ Args:
164
+ name: The name of the deprecated parameter.
165
+ version: The version in which the parameter was deprecated.
166
+ reason: The reason for deprecation and recommended alternative.
167
+ removal_version: The version in which the parameter will be removed.
168
+ stacklevel: Stack level for the warning (default 2 for immediate caller).
169
+
170
+ Example:
171
+ >>> def my_function(new_param: str, old_param: str | None = None):
172
+ ... if old_param is not None:
173
+ ... deprecated_parameter(
174
+ ... name="old_param",
175
+ ... version="1.2.0",
176
+ ... reason="Use new_param instead"
177
+ ... )
178
+ ... new_param = old_param
179
+ ... return new_param
180
+ """
181
+ message = f"Parameter '{name}' is deprecated since version {version}."
182
+
183
+ if removal_version:
184
+ message += f" It will be removed in version {removal_version}."
185
+
186
+ message += f" {reason}"
187
+
188
+ warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel + 1)
@@ -61,7 +61,7 @@ async def trigger_webhook(
61
61
  return None
62
62
 
63
63
  try:
64
- msg_id = cast(int, webhook_service.publish(event, data))
64
+ msg_id = cast("int", webhook_service.publish(event, data))
65
65
  _logger.info(f"Triggered webhook event '{event}' with message ID {msg_id}")
66
66
  return msg_id
67
67
  except Exception as e: