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.
- svc_infra/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {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
|
|
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:
|
|
23
|
+
self._task: asyncio.Task | None = None
|
|
26
24
|
self._stopping = asyncio.Event()
|
|
27
|
-
self._inflight:
|
|
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
|
|
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
|
-
|
|
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):
|
svc_infra/jobs/scheduler.py
CHANGED
|
@@ -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
|
|
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:
|
|
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(
|
|
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(
|
|
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
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
|
|
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[
|
|
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[
|
|
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[
|
|
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
|
svc_infra/loaders/github.py
CHANGED
|
@@ -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
|
svc_infra/logging/__init__.py
CHANGED
|
@@ -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
|
|
36
|
-
from typing import Any
|
|
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(
|
|
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(
|
|
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:
|
|
170
|
-
json_format:
|
|
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}")
|
svc_infra/mcp/svc_infra_mcp.py
CHANGED
|
@@ -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
|
|
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:
|
|
23
|
+
db_engines: Iterable[Any] | None = None,
|
|
23
24
|
metrics_path: str | None = None,
|
|
24
|
-
skip_metric_paths:
|
|
25
|
+
skip_metric_paths: Iterable[str] | None = None,
|
|
25
26
|
route_classifier: RouteClassifier | None = None,
|
|
26
27
|
) -> Callable[[], None]:
|
|
27
28
|
"""
|
svc_infra/obs/cloud_dash.py
CHANGED
|
@@ -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
|
|
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[[
|
|
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:
|
|
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)
|
svc_infra/obs/metrics/asgi.py
CHANGED
|
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import time
|
|
5
|
-
from
|
|
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:
|
|
119
|
-
route_resolver:
|
|
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(
|
svc_infra/obs/metrics/base.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import importlib
|
|
4
|
-
from
|
|
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 =
|
|
35
|
-
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:
|
|
49
|
+
labels: Iterable[str] | None = None,
|
|
50
50
|
**kwargs,
|
|
51
51
|
):
|
|
52
52
|
prom = _prom_mod()
|
|
53
|
-
Counter =
|
|
54
|
-
Gauge =
|
|
55
|
-
Histogram =
|
|
56
|
-
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:
|
|
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:
|
|
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:
|
|
83
|
-
buckets:
|
|
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:
|
|
91
|
+
def summary(name: str, doc: str, labels: Iterable[str] | None = None):
|
|
92
92
|
return _mk_metric("Summary", name, doc, labels)
|
svc_infra/obs/metrics/http.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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):
|
|
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):
|
|
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):
|
|
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
|
|
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[[
|
|
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:
|
|
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
|
-
|
|
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)",
|