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/jobs/runner.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import contextlib
5
- from typing import Awaitable, Callable, Optional
5
+ from collections.abc import Awaitable, Callable
6
6
 
7
7
  from .queue import JobQueue
8
8
 
@@ -16,15 +16,13 @@ class WorkerRunner:
16
16
  - stop(grace_seconds): signal stop, wait up to grace for current job to finish
17
17
  """
18
18
 
19
- def __init__(
20
- self, queue: JobQueue, handler: ProcessFunc, *, poll_interval: float = 0.25
21
- ):
19
+ def __init__(self, queue: JobQueue, handler: ProcessFunc, *, poll_interval: float = 0.25):
22
20
  self._queue = queue
23
21
  self._handler = handler
24
22
  self._poll_interval = poll_interval
25
- self._task: Optional[asyncio.Task] = None
23
+ self._task: asyncio.Task | None = None
26
24
  self._stopping = asyncio.Event()
27
- self._inflight: Optional[asyncio.Task] = None
25
+ self._inflight: asyncio.Task | None = None
28
26
 
29
27
  async def _loop(self) -> None:
30
28
  try:
@@ -63,16 +61,14 @@ class WorkerRunner:
63
61
  if self._inflight is not None and not self._inflight.done():
64
62
  try:
65
63
  await asyncio.wait_for(self._inflight, timeout=grace_seconds)
66
- except asyncio.TimeoutError:
64
+ except TimeoutError:
67
65
  # Give up; job will be retried if your queue supports visibility timeouts
68
66
  pass
69
67
  # Finally, wait for loop to exit (should be quick since stopping is set)
70
68
  if self._task is not None:
71
69
  try:
72
- await asyncio.wait_for(
73
- self._task, timeout=max(0.1, self._poll_interval + 0.1)
74
- )
75
- except asyncio.TimeoutError:
70
+ await asyncio.wait_for(self._task, timeout=max(0.1, self._poll_interval + 0.1))
71
+ except TimeoutError:
76
72
  # Cancel as a last resort
77
73
  self._task.cancel()
78
74
  with contextlib.suppress(Exception):
@@ -1,9 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ from collections.abc import Awaitable, Callable
4
5
  from dataclasses import dataclass
5
- from datetime import datetime, timedelta, timezone
6
- from typing import Awaitable, Callable, Dict
6
+ from datetime import UTC, datetime, timedelta
7
7
 
8
8
  CronFunc = Callable[[], Awaitable[None]]
9
9
 
@@ -23,11 +23,11 @@ class InMemoryScheduler:
23
23
  """
24
24
 
25
25
  def __init__(self, tick_interval: float = 60.0):
26
- self._tasks: Dict[str, ScheduledTask] = {}
26
+ self._tasks: dict[str, ScheduledTask] = {}
27
27
  self._tick_interval = tick_interval
28
28
 
29
29
  def add_task(self, name: str, interval_seconds: int, func: CronFunc) -> None:
30
- now = datetime.now(timezone.utc)
30
+ now = datetime.now(UTC)
31
31
  self._tasks[name] = ScheduledTask(
32
32
  name=name,
33
33
  interval_seconds=interval_seconds,
@@ -36,7 +36,7 @@ class InMemoryScheduler:
36
36
  )
37
37
 
38
38
  async def tick(self) -> None:
39
- now = datetime.now(timezone.utc)
39
+ now = datetime.now(UTC)
40
40
  for task in self._tasks.values():
41
41
  if task.next_run_at <= now:
42
42
  await task.func()
svc_infra/jobs/worker.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import os
5
- from typing import Awaitable, Callable
5
+ from collections.abc import Awaitable, Callable
6
6
 
7
7
  from .queue import Job, JobQueue
8
8
 
svc_infra/loaders/base.py CHANGED
@@ -8,7 +8,8 @@ from __future__ import annotations
8
8
  import asyncio
9
9
  import logging
10
10
  from abc import ABC, abstractmethod
11
- from typing import TYPE_CHECKING, AsyncIterator, Literal
11
+ from collections.abc import AsyncIterator
12
+ from typing import TYPE_CHECKING, Literal
12
13
 
13
14
  if TYPE_CHECKING:
14
15
  from .models import LoadedContent
@@ -57,7 +58,7 @@ class BaseLoader(ABC):
57
58
  self.on_error = on_error
58
59
 
59
60
  @abstractmethod
60
- async def load(self) -> list["LoadedContent"]:
61
+ async def load(self) -> list[LoadedContent]:
61
62
  """Load all content from the source.
62
63
 
63
64
  This is the main method that subclasses must implement.
@@ -70,7 +71,7 @@ class BaseLoader(ABC):
70
71
  """
71
72
  ...
72
73
 
73
- async def aiter(self) -> AsyncIterator["LoadedContent"]:
74
+ async def aiter(self) -> AsyncIterator[LoadedContent]:
74
75
  """Iterate over loaded content asynchronously.
75
76
 
76
77
  This is useful for progress tracking or streaming large datasets.
@@ -88,7 +89,7 @@ class BaseLoader(ABC):
88
89
  for content in await self.load():
89
90
  yield content
90
91
 
91
- def load_sync(self) -> list["LoadedContent"]:
92
+ def load_sync(self) -> list[LoadedContent]:
92
93
  """Synchronous wrapper for load().
93
94
 
94
95
  Creates a new event loop if needed. Prefer the async version
@@ -142,9 +142,7 @@ class GitHubLoader(BaseLoader):
142
142
 
143
143
  # Validate repo format
144
144
  if "/" not in repo or repo.count("/") != 1:
145
- raise ValueError(
146
- f"Invalid repo format: {repo!r}. Expected 'owner/repo' format."
147
- )
145
+ raise ValueError(f"Invalid repo format: {repo!r}. Expected 'owner/repo' format.")
148
146
 
149
147
  self.repo = repo
150
148
  self.path = path.strip("/")
svc_infra/loaders/url.py CHANGED
@@ -97,9 +97,7 @@ class URLLoader(BaseLoader):
97
97
  # Validate URLs
98
98
  for url in self.urls:
99
99
  if not url.startswith(("http://", "https://")):
100
- raise ValueError(
101
- f"Invalid URL: {url!r}. URLs must start with http:// or https://"
102
- )
100
+ raise ValueError(f"Invalid URL: {url!r}. URLs must start with http:// or https://")
103
101
 
104
102
  async def load(self) -> list[LoadedContent]:
105
103
  """Load content from all URLs.
@@ -132,9 +130,7 @@ class URLLoader(BaseLoader):
132
130
  content = raw_content
133
131
 
134
132
  # Parse content type (remove charset etc.)
135
- mime_type = (
136
- content_type.split(";")[0].strip() if content_type else None
137
- )
133
+ mime_type = content_type.split(";")[0].strip() if content_type else None
138
134
 
139
135
  loaded = LoadedContent(
140
136
  content=content,
@@ -183,9 +179,7 @@ class URLLoader(BaseLoader):
183
179
  soup = BeautifulSoup(html, "html.parser")
184
180
 
185
181
  # Remove non-content elements
186
- for tag in soup(
187
- ["script", "style", "nav", "footer", "header", "aside", "noscript"]
188
- ):
182
+ for tag in soup(["script", "style", "nav", "footer", "header", "aside", "noscript"]):
189
183
  tag.decompose()
190
184
 
191
185
  # Get text with newlines preserved
@@ -31,9 +31,10 @@ import json
31
31
  import logging
32
32
  import os
33
33
  import sys
34
+ from collections.abc import Iterator
34
35
  from contextlib import contextmanager
35
- from datetime import datetime, timezone
36
- from typing import Any, Iterator, Optional
36
+ from datetime import UTC, datetime
37
+ from typing import Any
37
38
 
38
39
  # Context variables for structured logging
39
40
  _log_context: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
@@ -89,7 +90,7 @@ class JsonFormatter(logging.Formatter):
89
90
  """Format a log record as JSON."""
90
91
  # Base log structure
91
92
  log_dict: dict[str, Any] = {
92
- "timestamp": datetime.now(timezone.utc).isoformat(),
93
+ "timestamp": datetime.now(UTC).isoformat(),
93
94
  "level": record.levelname,
94
95
  "logger": record.name,
95
96
  "message": record.getMessage(),
@@ -149,7 +150,7 @@ class TextFormatter(logging.Formatter):
149
150
 
150
151
  def format(self, record: logging.LogRecord) -> str:
151
152
  """Format a log record as human-readable text."""
152
- timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
153
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
153
154
  base = f"{timestamp} [{record.levelname}] {record.name}: {record.getMessage()}"
154
155
 
155
156
  # Add context if present
@@ -166,8 +167,8 @@ class TextFormatter(logging.Formatter):
166
167
 
167
168
 
168
169
  def configure_for_container(
169
- level: Optional[str] = None,
170
- json_format: Optional[bool] = None,
170
+ level: str | None = None,
171
+ json_format: bool | None = None,
171
172
  stream: Any = None,
172
173
  ) -> None:
173
174
  """
svc_infra/mcp/__init__.py CHANGED
@@ -0,0 +1,82 @@
1
+ """MCP (Model Context Protocol) server for svc-infra CLI.
2
+
3
+ This module provides an MCP server that exposes svc-infra CLI commands as tools
4
+ for AI assistants and agents.
5
+
6
+ Available Tools:
7
+ - svc_infra_cmd_help: Get help text for the svc-infra CLI
8
+ - svc_infra_subcmd_help: Get help for specific subcommands
9
+ - svc_infra_docs_help: Get documentation help
10
+
11
+ Example:
12
+ # Run the MCP server
13
+ python -m svc_infra.mcp.svc_infra_mcp
14
+
15
+ # Or use programmatically
16
+ from svc_infra.mcp import mcp, Subcommand, svc_infra_subcmd_help
17
+
18
+ # Get help for a subcommand
19
+ result = await svc_infra_subcmd_help(Subcommand.sql_upgrade)
20
+
21
+ See Also:
22
+ - ai-infra MCP documentation for client usage
23
+ - svc-infra CLI reference for available commands
24
+
25
+ Note:
26
+ This module requires ai-infra to be installed. If ai-infra is not available,
27
+ imports will raise ImportError with a helpful message.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import TYPE_CHECKING
33
+
34
+ if TYPE_CHECKING:
35
+ from .svc_infra_mcp import (
36
+ CLI_PROG as CLI_PROG,
37
+ )
38
+ from .svc_infra_mcp import (
39
+ Subcommand as Subcommand,
40
+ )
41
+ from .svc_infra_mcp import (
42
+ mcp as mcp,
43
+ )
44
+ from .svc_infra_mcp import (
45
+ svc_infra_cmd_help as svc_infra_cmd_help,
46
+ )
47
+ from .svc_infra_mcp import (
48
+ svc_infra_docs_help as svc_infra_docs_help,
49
+ )
50
+ from .svc_infra_mcp import (
51
+ svc_infra_subcmd_help as svc_infra_subcmd_help,
52
+ )
53
+
54
+ __all__ = [
55
+ # MCP server instance
56
+ "mcp",
57
+ # Subcommand enum
58
+ "Subcommand",
59
+ # Tool functions
60
+ "svc_infra_cmd_help",
61
+ "svc_infra_subcmd_help",
62
+ "svc_infra_docs_help",
63
+ # Constants
64
+ "CLI_PROG",
65
+ ]
66
+
67
+
68
+ def __getattr__(name: str):
69
+ """Lazy import to defer ai-infra dependency until runtime."""
70
+ if name in __all__:
71
+ try:
72
+ from . import svc_infra_mcp
73
+
74
+ return getattr(svc_infra_mcp, name)
75
+ except ImportError as e:
76
+ if "ai_infra" in str(e):
77
+ raise ImportError(
78
+ f"Cannot import '{name}' from svc_infra.mcp: "
79
+ "ai-infra package is required. Install with: pip install ai-infra"
80
+ ) from e
81
+ raise
82
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -18,7 +18,7 @@ async def svc_infra_cmd_help() -> dict[Any, Any]:
18
18
  - Prepares project env without chdir (so we can 'cd' in the command itself).
19
19
  - Tries poetry → console script → python -m svc_infra.cli_shim.
20
20
  """
21
- return cast(dict[Any, Any], await cli_cmd_help(CLI_PROG))
21
+ return cast("dict[Any, Any]", await cli_cmd_help(CLI_PROG))
22
22
 
23
23
 
24
24
  # No dedicated 'docs list' function — users can use 'docs --help' to discover topics.
@@ -97,7 +97,7 @@ async def svc_infra_subcmd_help(subcommand: Subcommand) -> dict[Any, Any]:
97
97
  """
98
98
  tokens = subcommand.value.split()
99
99
  if len(tokens) == 1:
100
- return cast(dict[Any, Any], await cli_subcmd_help(CLI_PROG, subcommand))
100
+ return cast("dict[Any, Any]", await cli_subcmd_help(CLI_PROG, subcommand))
101
101
 
102
102
  root = prepare_env()
103
103
  text = await run_from_root(root, CLI_PROG, [*tokens, "--help"])
svc_infra/obs/add.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Iterable, Optional, Protocol
3
+ from collections.abc import Callable, Iterable
4
+ from typing import Any, Protocol
4
5
 
5
6
  from svc_infra.obs.settings import ObservabilitySettings
6
7
 
@@ -19,9 +20,9 @@ class RouteClassifier(Protocol):
19
20
  def add_observability(
20
21
  app: Any | None = None,
21
22
  *,
22
- db_engines: Optional[Iterable[Any]] = None,
23
+ db_engines: Iterable[Any] | None = None,
23
24
  metrics_path: str | None = None,
24
- skip_metric_paths: Optional[Iterable[str]] = None,
25
+ skip_metric_paths: Iterable[str] | None = None,
25
26
  route_classifier: RouteClassifier | None = None,
26
27
  ) -> Callable[[], None]:
27
28
  """
@@ -87,7 +87,7 @@ def _rewrite_rate_windows(d: dict) -> dict:
87
87
  if not win:
88
88
  return d
89
89
 
90
- dd = cast(dict[Any, Any], json.loads(json.dumps(d)))
90
+ dd = cast("dict[Any, Any]", json.loads(json.dumps(d)))
91
91
  for p in dd.get("panels", []) or []:
92
92
  targets = p.get("targets") or []
93
93
  for t in targets:
@@ -6,7 +6,7 @@ plug in logging or a metrics backend without a hard dependency.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import Callable, Optional
9
+ from collections.abc import Callable
10
10
 
11
11
  # Function variables so applications/tests can replace them at runtime.
12
12
  on_rate_limit_exceeded: Callable[[str, int, int], None] | None = None
@@ -18,7 +18,7 @@ Args:
18
18
  retry_after: seconds until next allowed attempt
19
19
  """
20
20
 
21
- on_suspect_payload: Callable[[Optional[str], int], None] | None = None
21
+ on_suspect_payload: Callable[[str | None, int], None] | None = None
22
22
  """
23
23
  Called when a request exceeds the configured size limit.
24
24
  Args:
@@ -36,7 +36,7 @@ def emit_rate_limited(key: str, limit: int, retry_after: int) -> None:
36
36
  pass
37
37
 
38
38
 
39
- def emit_suspect_payload(path: Optional[str], size: int) -> None:
39
+ def emit_suspect_payload(path: str | None, size: int) -> None:
40
40
  if on_suspect_payload:
41
41
  try:
42
42
  on_suspect_payload(path, size)
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import time
5
- from typing import Any, Callable, Iterable, Optional, cast
5
+ from collections.abc import Callable, Iterable
6
+ from typing import Any, cast
6
7
 
7
8
  from starlette.requests import Request
8
9
  from starlette.responses import PlainTextResponse, Response
@@ -97,9 +98,9 @@ def _init_metrics() -> None:
97
98
  def _route_template(req: Request) -> str:
98
99
  route = getattr(req, "scope", {}).get("route")
99
100
  if route and hasattr(route, "path_format"):
100
- return cast(str, route.path_format)
101
+ return cast("str", route.path_format)
101
102
  if route and hasattr(route, "path"):
102
- return cast(str, route.path)
103
+ return cast("str", route.path)
103
104
  return req.url.path or "/*unmatched*"
104
105
 
105
106
 
@@ -115,8 +116,8 @@ class PrometheusMiddleware:
115
116
  self,
116
117
  app: ASGIApp,
117
118
  *,
118
- skip_paths: Optional[Iterable[str]] = None,
119
- route_resolver: Optional[Callable[[Request], str]] = None,
119
+ skip_paths: Iterable[str] | None = None,
120
+ route_resolver: Callable[[Request], str] | None = None,
120
121
  ):
121
122
  self.app = app
122
123
  self.skip_paths = tuple(skip_paths or ("/metrics",))
@@ -186,13 +187,9 @@ class PrometheusMiddleware:
186
187
  if _http_requests_total:
187
188
  _http_requests_total.labels(method, route_for_stats, code).inc()
188
189
  if _http_request_duration:
189
- _http_request_duration.labels(route_for_stats, method).observe(
190
- elapsed
191
- )
190
+ _http_request_duration.labels(route_for_stats, method).observe(elapsed)
192
191
  if _http_response_size:
193
- _http_response_size.labels(route_for_stats, method).observe(
194
- bytes_sent
195
- )
192
+ _http_response_size.labels(route_for_stats, method).observe(bytes_sent)
196
193
  except Exception:
197
194
  pass
198
195
  try:
@@ -241,9 +238,7 @@ def metrics_endpoint():
241
238
  return handler
242
239
 
243
240
 
244
- def add_prometheus(
245
- app, *, path: str = "/metrics", skip_paths: Optional[Iterable[str]] = None
246
- ):
241
+ def add_prometheus(app, *, path: str = "/metrics", skip_paths: Iterable[str] | None = None):
247
242
  """Convenience for FastAPI/Starlette apps."""
248
243
  # Add middleware
249
244
  app.add_middleware(
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib
4
- from typing import Iterable, Optional
4
+ from collections.abc import Iterable
5
5
 
6
6
 
7
7
  class _MissingPrometheus(Exception):
@@ -31,8 +31,8 @@ def registry():
31
31
  import os
32
32
 
33
33
  prom = _prom_mod()
34
- REGISTRY = getattr(prom, "REGISTRY")
35
- CollectorRegistry = getattr(prom, "CollectorRegistry")
34
+ REGISTRY = prom.REGISTRY
35
+ CollectorRegistry = prom.CollectorRegistry
36
36
  multiprocess = getattr(prom, "multiprocess", None)
37
37
 
38
38
  if os.environ.get("PROMETHEUS_MULTIPROC_DIR") and multiprocess is not None:
@@ -46,14 +46,14 @@ def _mk_metric(
46
46
  ctor_name: str,
47
47
  name: str,
48
48
  doc: str,
49
- labels: Optional[Iterable[str]] = None,
49
+ labels: Iterable[str] | None = None,
50
50
  **kwargs,
51
51
  ):
52
52
  prom = _prom_mod()
53
- Counter = getattr(prom, "Counter")
54
- Gauge = getattr(prom, "Gauge")
55
- Histogram = getattr(prom, "Histogram")
56
- Summary = getattr(prom, "Summary")
53
+ Counter = prom.Counter
54
+ Gauge = prom.Gauge
55
+ Histogram = prom.Histogram
56
+ Summary = prom.Summary
57
57
 
58
58
  ctors = {
59
59
  "Counter": Counter,
@@ -67,11 +67,11 @@ def _mk_metric(
67
67
  return metric
68
68
 
69
69
 
70
- def counter(name: str, doc: str, labels: Optional[Iterable[str]] = None):
70
+ def counter(name: str, doc: str, labels: Iterable[str] | None = None):
71
71
  return _mk_metric("Counter", name, doc, labels)
72
72
 
73
73
 
74
- def gauge(name: str, doc: str, labels: Optional[Iterable[str]] = None, **kw):
74
+ def gauge(name: str, doc: str, labels: Iterable[str] | None = None, **kw):
75
75
  # e.g. gauge(..., multiprocess_mode="livesum")
76
76
  return _mk_metric("Gauge", name, doc, labels, **kw)
77
77
 
@@ -79,8 +79,8 @@ def gauge(name: str, doc: str, labels: Optional[Iterable[str]] = None, **kw):
79
79
  def histogram(
80
80
  name: str,
81
81
  doc: str,
82
- labels: Optional[Iterable[str]] = None,
83
- buckets: Optional[Iterable[float]] = None,
82
+ labels: Iterable[str] | None = None,
83
+ buckets: Iterable[float] | None = None,
84
84
  ):
85
85
  kwargs = {"buckets": list(buckets) if buckets else None}
86
86
  # Remove None so prometheus-client uses its defaults
@@ -88,5 +88,5 @@ def histogram(
88
88
  return _mk_metric("Histogram", name, doc, labels, **kwargs)
89
89
 
90
90
 
91
- def summary(name: str, doc: str, labels: Optional[Iterable[str]] = None):
91
+ def summary(name: str, doc: str, labels: Iterable[str] | None = None):
92
92
  return _mk_metric("Summary", name, doc, labels)
@@ -51,7 +51,7 @@ def instrument_requests():
51
51
  _http_client_total.labels(host, method_u, code).inc()
52
52
  _http_client_duration.labels(host, method_u).observe(elapsed)
53
53
 
54
- setattr(requests.sessions.Session, "request", _wrapped)
54
+ requests.sessions.Session.request = _wrapped # type: ignore[method-assign]
55
55
 
56
56
 
57
57
  def instrument_httpx():
@@ -74,9 +74,7 @@ def instrument_httpx():
74
74
  raise
75
75
  finally:
76
76
  _http_client_total.labels(host, method, code).inc()
77
- _http_client_duration.labels(host, method).observe(
78
- time.perf_counter() - start
79
- )
77
+ _http_client_duration.labels(host, method).observe(time.perf_counter() - start)
80
78
 
81
79
  return _wrapped
82
80
 
@@ -93,9 +91,7 @@ def instrument_httpx():
93
91
  raise
94
92
  finally:
95
93
  _http_client_total.labels(host, method, code).inc()
96
- _http_client_duration.labels(host, method).observe(
97
- time.perf_counter() - start
98
- )
94
+ _http_client_duration.labels(host, method).observe(time.perf_counter() - start)
99
95
 
100
- setattr(httpx.Client, "send", _wrap_sync_send(_orig_sync))
101
- setattr(httpx.AsyncClient, "send", _wrapped_async)
96
+ httpx.Client.send = _wrap_sync_send(_orig_sync) # type: ignore[method-assign]
97
+ httpx.AsyncClient.send = _wrapped_async # type: ignore[method-assign]
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Mapping, Optional
3
+ from collections.abc import Mapping
4
+ from typing import Any
4
5
 
5
6
  from sqlalchemy.engine import Engine
6
7
 
@@ -23,20 +24,16 @@ _pool_available = gauge(
23
24
  labels=["db"],
24
25
  multiprocess_mode="livesum",
25
26
  )
26
- _pool_checked_out_total = counter(
27
- "db_pool_checkedout_total", "Total checkouts", labels=["db"]
28
- )
29
- _pool_checked_in_total = counter(
30
- "db_pool_checkedin_total", "Total checkins", labels=["db"]
31
- )
27
+ _pool_checked_out_total = counter("db_pool_checkedout_total", "Total checkouts", labels=["db"])
28
+ _pool_checked_in_total = counter("db_pool_checkedin_total", "Total checkins", labels=["db"])
32
29
 
33
30
 
34
- def _label(labels: Optional[Mapping[str, str]]) -> str:
31
+ def _label(labels: Mapping[str, str] | None) -> str:
35
32
  return (labels or {}).get("db", "default")
36
33
 
37
34
 
38
35
  def bind_sqlalchemy_pool_metrics(
39
- engine: Engine | Any, labels: Optional[Mapping[str, str]] = None
36
+ engine: Engine | Any, labels: Mapping[str, str] | None = None
40
37
  ) -> None:
41
38
  """Bind event listeners for pool metrics. Works for sync Engine.
42
39
  For AsyncEngine pass engine.sync_engine."""
@@ -46,7 +43,7 @@ def bind_sqlalchemy_pool_metrics(
46
43
  from sqlalchemy import event
47
44
 
48
45
  @event.listens_for(sync_engine, "engine_connect")
49
- def _(conn, branch): # noqa
46
+ def _(conn, branch):
50
47
  # Update gauges on engine_connect as a cheap heartbeat
51
48
  pool = sync_engine.pool
52
49
  try:
@@ -56,7 +53,7 @@ def bind_sqlalchemy_pool_metrics(
56
53
  pass
57
54
 
58
55
  @event.listens_for(sync_engine, "checkout")
59
- def _checkout(dbapi_con, con_record, con_proxy): # noqa
56
+ def _checkout(dbapi_con, con_record, con_proxy):
60
57
  _pool_checked_out_total.labels(label).inc()
61
58
  try:
62
59
  pool = sync_engine.pool
@@ -66,7 +63,7 @@ def bind_sqlalchemy_pool_metrics(
66
63
  pass
67
64
 
68
65
  @event.listens_for(sync_engine, "checkin")
69
- def _checkin(dbapi_con, con_record): # noqa
66
+ def _checkin(dbapi_con, con_record):
70
67
  _pool_checked_in_total.labels(label).inc()
71
68
  try:
72
69
  pool = sync_engine.pool
svc_infra/obs/metrics.py CHANGED
@@ -7,7 +7,7 @@ functions.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from typing import Callable, Optional
10
+ from collections.abc import Callable
11
11
 
12
12
  # Function variables so applications/tests can replace them at runtime.
13
13
  on_rate_limit_exceeded: Callable[[str, int, int], None] | None = None
@@ -19,7 +19,7 @@ Args:
19
19
  retry_after: seconds until next allowed attempt
20
20
  """
21
21
 
22
- on_suspect_payload: Callable[[Optional[str], int], None] | None = None
22
+ on_suspect_payload: Callable[[str | None, int], None] | None = None
23
23
  """
24
24
  Called when a request exceeds the configured size limit.
25
25
  Args:
@@ -37,7 +37,7 @@ def emit_rate_limited(key: str, limit: int, retry_after: int) -> None:
37
37
  pass
38
38
 
39
39
 
40
- def emit_suspect_payload(path: Optional[str], size: int) -> None:
40
+ def emit_suspect_payload(path: str | None, size: int) -> None:
41
41
  if on_suspect_payload:
42
42
  try:
43
43
  on_suspect_payload(path, size)
svc_infra/obs/settings.py CHANGED
@@ -14,12 +14,8 @@ class ObservabilitySettings(BaseSettings):
14
14
  - METRICS_DEFAULT_BUCKETS=comma-separated seconds (optional)
15
15
  """
16
16
 
17
- METRICS_ENABLED: bool = Field(
18
- default=True, description="Enable Prometheus metrics exposure"
19
- )
20
- METRICS_PATH: str = Field(
21
- default="/metrics", description="HTTP path for metrics endpoint"
22
- )
17
+ METRICS_ENABLED: bool = Field(default=True, description="Enable Prometheus metrics exposure")
18
+ METRICS_PATH: str = Field(default="/metrics", description="HTTP path for metrics endpoint")
23
19
  METRICS_DEFAULT_BUCKETS: tuple[float, ...] = Field(
24
20
  default=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0),
25
21
  description="Default histogram buckets (seconds)",