svc-infra 0.1.597__py3-none-any.whl → 0.1.599__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/db/inbox.py CHANGED
@@ -13,6 +13,10 @@ class InboxStore(Protocol):
13
13
  """Optional: remove expired keys, return number purged."""
14
14
  ...
15
15
 
16
+ def is_marked(self, key: str) -> bool:
17
+ """Return True if key is already marked (not expired), without modifying it."""
18
+ ...
19
+
16
20
 
17
21
  class InMemoryInboxStore:
18
22
  def __init__(self) -> None:
@@ -33,6 +37,11 @@ class InMemoryInboxStore:
33
37
  self._keys.pop(k, None)
34
38
  return len(to_del)
35
39
 
40
+ def is_marked(self, key: str) -> bool:
41
+ now = time.time()
42
+ exp = self._keys.get(key)
43
+ return bool(exp and exp > now)
44
+
36
45
 
37
46
  class SqlInboxStore:
38
47
  """Skeleton for a SQL-backed inbox store (dedupe table).
@@ -53,3 +62,6 @@ class SqlInboxStore:
53
62
 
54
63
  def purge_expired(self) -> int: # pragma: no cover - skeleton
55
64
  raise NotImplementedError
65
+
66
+ def is_marked(self, key: str) -> bool: # pragma: no cover - skeleton
67
+ raise NotImplementedError
@@ -1,20 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- import hashlib
4
- import hmac
5
- import json
6
- from typing import Dict
7
-
8
3
  import httpx
9
4
 
10
5
  from svc_infra.db.inbox import InboxStore
11
6
  from svc_infra.db.outbox import OutboxStore
12
7
  from svc_infra.jobs.queue import Job
13
-
14
-
15
- def _compute_signature(secret: str, payload: Dict) -> str:
16
- body = json.dumps(payload, separators=(",", ":")).encode()
17
- return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
8
+ from svc_infra.webhooks.signing import sign
18
9
 
19
10
 
20
11
  def make_webhook_handler(
@@ -39,18 +30,46 @@ def make_webhook_handler(
39
30
  if not outbox_id or not topic:
40
31
  # Nothing we can do; ack to avoid poison loop
41
32
  return
42
- # dedupe by outbox_id via inbox
33
+ # dedupe marker key (marked after successful delivery)
43
34
  key = f"webhook:{outbox_id}"
44
- if not inbox.mark_if_new(key, ttl_seconds=24 * 3600):
35
+ if inbox.is_marked(key):
45
36
  # already delivered
46
37
  outbox.mark_processed(int(outbox_id))
47
38
  return
48
- url = get_webhook_url_for_topic(topic)
49
- secret = get_secret_for_topic(topic)
50
- sig = _compute_signature(secret, payload)
39
+ event = payload.get("event") if isinstance(payload, dict) else None
40
+ subscription = payload.get("subscription") if isinstance(payload, dict) else None
41
+ if event is not None and subscription is not None:
42
+ delivery_payload = event
43
+ url = subscription.get("url") or get_webhook_url_for_topic(topic)
44
+ secret = subscription.get("secret") or get_secret_for_topic(topic)
45
+ subscription_id = subscription.get("id")
46
+ else:
47
+ delivery_payload = payload
48
+ url = get_webhook_url_for_topic(topic)
49
+ secret = get_secret_for_topic(topic)
50
+ subscription_id = None
51
+ sig = sign(secret, delivery_payload)
52
+ headers = {
53
+ header_name: sig,
54
+ "X-Event-Id": str(outbox_id),
55
+ "X-Topic": str(topic),
56
+ "X-Attempt": str(job.attempts or 1),
57
+ "X-Signature-Alg": "hmac-sha256",
58
+ "X-Signature-Version": "v1",
59
+ }
60
+ if subscription_id:
61
+ headers["X-Webhook-Subscription"] = str(subscription_id)
62
+ # include event payload version if present
63
+ version = None
64
+ if isinstance(delivery_payload, dict):
65
+ version = delivery_payload.get("version")
66
+ if version is not None:
67
+ headers["X-Payload-Version"] = str(version)
51
68
  async with httpx.AsyncClient(timeout=10) as client:
52
- resp = await client.post(url, json=payload, headers={header_name: sig})
69
+ resp = await client.post(url, json=delivery_payload, headers=headers)
53
70
  if 200 <= resp.status_code < 300:
71
+ # record delivery and mark processed
72
+ inbox.mark_if_new(key, ttl_seconds=24 * 3600)
54
73
  outbox.mark_processed(int(outbox_id))
55
74
  return
56
75
  # allow retry on non-2xx: raise to trigger fail/backoff
svc_infra/obs/README.md CHANGED
@@ -8,6 +8,8 @@ This guide shows you how to turn on metrics + dashboards in three easy modes:
8
8
 
9
9
  It's "one button": run `svc-infra obs-up` and you're good. The CLI will read your `.env` automatically and do the right thing.
10
10
 
11
+ > ℹ️ A complete list of observability-related environment variables lives in [Environment Reference](../../../docs/environment.md).
12
+
11
13
  ---
12
14
 
13
15
  ## 0) Install & instrument your app (once)
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Mapping
5
+ from typing import Iterable
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from starlette.middleware.sessions import SessionMiddleware
10
+
11
+ from svc_infra.security.headers import SECURE_DEFAULTS, SecurityHeadersMiddleware
12
+
13
+ DEFAULT_SESSION_SECRET = "svc-dev-secret-change-me"
14
+
15
+
16
+ def _parse_bool(value: str | None) -> bool | None:
17
+ if value is None:
18
+ return None
19
+ lowered = value.strip().lower()
20
+ if lowered in {"1", "true", "yes", "on"}:
21
+ return True
22
+ if lowered in {"0", "false", "no", "off"}:
23
+ return False
24
+ return None
25
+
26
+
27
+ def _normalize_origins(value: Iterable[str] | str | None) -> list[str]:
28
+ if value is None:
29
+ return []
30
+ if isinstance(value, str):
31
+ parts = [p.strip() for p in value.split(",")]
32
+ else:
33
+ parts = [str(v).strip() for v in value]
34
+ return [p for p in parts if p]
35
+
36
+
37
+ def _resolve_cors_origins(
38
+ provided: Iterable[str] | str | None,
39
+ env: Mapping[str, str],
40
+ ) -> list[str]:
41
+ if provided is not None:
42
+ return _normalize_origins(provided)
43
+ return _normalize_origins(env.get("CORS_ALLOW_ORIGINS"))
44
+
45
+
46
+ def _resolve_allow_credentials(
47
+ allow_credentials: bool,
48
+ env: Mapping[str, str],
49
+ ) -> bool:
50
+ env_value = _parse_bool(env.get("CORS_ALLOW_CREDENTIALS"))
51
+ if env_value is None:
52
+ return allow_credentials
53
+ # Allow explicit overrides via function arguments.
54
+ if allow_credentials is not True:
55
+ return allow_credentials
56
+ return env_value
57
+
58
+
59
+ def _configure_cors(
60
+ app: FastAPI,
61
+ *,
62
+ cors_origins: Iterable[str] | str | None,
63
+ allow_credentials: bool,
64
+ env: Mapping[str, str],
65
+ ) -> None:
66
+ origins = _resolve_cors_origins(cors_origins, env)
67
+ if not origins:
68
+ return
69
+
70
+ allow_methods = _normalize_origins(env.get("CORS_ALLOW_METHODS")) or ["*"]
71
+ allow_headers = _normalize_origins(env.get("CORS_ALLOW_HEADERS")) or ["*"]
72
+
73
+ credentials = _resolve_allow_credentials(allow_credentials, env)
74
+
75
+ wildcard_origins = "*" in origins
76
+
77
+ cors_kwargs: dict[str, object] = {
78
+ "allow_credentials": credentials,
79
+ "allow_methods": allow_methods,
80
+ "allow_headers": allow_headers,
81
+ "allow_origins": ["*"] if wildcard_origins else origins,
82
+ }
83
+ origin_regex = env.get("CORS_ALLOW_ORIGIN_REGEX")
84
+ if wildcard_origins:
85
+ cors_kwargs["allow_origin_regex"] = origin_regex or ".*"
86
+ else:
87
+ if origin_regex:
88
+ cors_kwargs["allow_origin_regex"] = origin_regex
89
+
90
+ app.add_middleware(CORSMiddleware, **cors_kwargs)
91
+
92
+
93
+ def _configure_security_headers(
94
+ app: FastAPI,
95
+ *,
96
+ overrides: dict[str, str] | None,
97
+ enable_hsts_preload: bool | None,
98
+ ) -> None:
99
+ merged_overrides = dict(overrides or {})
100
+ if enable_hsts_preload is not None:
101
+ current = merged_overrides.get(
102
+ "Strict-Transport-Security",
103
+ SECURE_DEFAULTS["Strict-Transport-Security"],
104
+ )
105
+ directives = [p.strip() for p in current.split(";") if p.strip()]
106
+ directives = [d for d in directives if d.lower() != "preload"]
107
+ if enable_hsts_preload:
108
+ directives.append("preload")
109
+ merged_overrides["Strict-Transport-Security"] = "; ".join(directives)
110
+
111
+ app.add_middleware(SecurityHeadersMiddleware, overrides=merged_overrides)
112
+
113
+
114
+ def _should_add_session_middleware(app: FastAPI) -> bool:
115
+ return not any(m.cls is SessionMiddleware for m in app.user_middleware)
116
+
117
+
118
+ def _configure_session_middleware(
119
+ app: FastAPI,
120
+ *,
121
+ env: Mapping[str, str],
122
+ install: bool,
123
+ secret_key: str | None,
124
+ session_cookie: str,
125
+ max_age: int,
126
+ same_site: str,
127
+ https_only: bool | None,
128
+ ) -> None:
129
+ if not install or not _should_add_session_middleware(app):
130
+ return
131
+
132
+ secret = secret_key or env.get("SESSION_SECRET") or DEFAULT_SESSION_SECRET
133
+ https_env = _parse_bool(env.get("SESSION_COOKIE_SECURE"))
134
+ effective_https_only = (
135
+ https_only if https_only is not None else (https_env if https_env is not None else False)
136
+ )
137
+ same_site_env = env.get("SESSION_COOKIE_SAMESITE")
138
+ same_site_value = same_site_env.strip() if same_site_env else same_site
139
+
140
+ max_age_env = env.get("SESSION_COOKIE_MAX_AGE_SECONDS")
141
+ try:
142
+ max_age_value = int(max_age_env) if max_age_env is not None else max_age
143
+ except ValueError:
144
+ max_age_value = max_age
145
+
146
+ session_cookie_env = env.get("SESSION_COOKIE_NAME")
147
+ session_cookie_value = session_cookie_env.strip() if session_cookie_env else session_cookie
148
+
149
+ app.add_middleware(
150
+ SessionMiddleware,
151
+ secret_key=secret,
152
+ session_cookie=session_cookie_value,
153
+ max_age=max_age_value,
154
+ same_site=same_site_value,
155
+ https_only=effective_https_only,
156
+ )
157
+
158
+
159
+ def add_security(
160
+ app: FastAPI,
161
+ *,
162
+ cors_origins: Iterable[str] | str | None = None,
163
+ headers_overrides: dict[str, str] | None = None,
164
+ allow_credentials: bool = True,
165
+ env: Mapping[str, str] = os.environ,
166
+ enable_hsts_preload: bool | None = None,
167
+ install_session_middleware: bool = False,
168
+ session_secret_key: str | None = None,
169
+ session_cookie_name: str = "svc_session",
170
+ session_cookie_max_age_seconds: int = 4 * 3600,
171
+ session_cookie_samesite: str = "lax",
172
+ session_cookie_https_only: bool | None = None,
173
+ ) -> None:
174
+ """Install security middlewares with svc-infra defaults."""
175
+
176
+ _configure_security_headers(
177
+ app,
178
+ overrides=headers_overrides,
179
+ enable_hsts_preload=enable_hsts_preload,
180
+ )
181
+ _configure_cors(
182
+ app,
183
+ cors_origins=cors_origins,
184
+ allow_credentials=allow_credentials,
185
+ env=env,
186
+ )
187
+ _configure_session_middleware(
188
+ app,
189
+ env=env,
190
+ install=install_session_middleware,
191
+ secret_key=session_secret_key,
192
+ session_cookie=session_cookie_name,
193
+ max_age=session_cookie_max_age_seconds,
194
+ same_site=session_cookie_samesite,
195
+ https_only=session_cookie_https_only,
196
+ )
197
+
198
+
199
+ __all__ = [
200
+ "add_security",
201
+ ]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING: # pragma: no cover - for type checkers only
6
+ from .add import add_webhooks
7
+
8
+ __all__ = ["add_webhooks"]
9
+
10
+
11
+ def __getattr__(name: str):
12
+ if name == "add_webhooks":
13
+ from .add import add_webhooks
14
+
15
+ return add_webhooks
16
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,322 @@
1
+ from __future__ import annotations
2
+
3
+ """FastAPI integration helpers for the webhooks router.
4
+
5
+ The :func:`add_webhooks` helper wires the public router into an app and makes
6
+ sure dependency overrides share a single set of stores instead of the in-file
7
+ defaults that create a new in-memory object per request. Callers can:
8
+
9
+ * rely on the in-memory defaults (suitable for tests / local usage);
10
+ * configure persistent stores through environment variables; or
11
+ * provide concrete instances / factories explicitly via keyword arguments.
12
+
13
+ When queue / scheduler objects are provided the helper also wires up the
14
+ standard outbox tick task and webhook delivery job handler so the caller only
15
+ needs to start their existing worker loop.
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from collections.abc import Callable, Mapping
22
+ from datetime import datetime, timezone
23
+ from typing import Any, Protocol, TypeVar, overload
24
+
25
+ from fastapi import FastAPI
26
+
27
+ from svc_infra.db.inbox import InboxStore, InMemoryInboxStore
28
+ from svc_infra.db.outbox import InMemoryOutboxStore, OutboxMessage, OutboxStore
29
+ from svc_infra.jobs.builtins.outbox_processor import make_outbox_tick
30
+ from svc_infra.jobs.builtins.webhook_delivery import make_webhook_handler
31
+ from svc_infra.jobs.queue import JobQueue
32
+ from svc_infra.jobs.scheduler import InMemoryScheduler
33
+
34
+ from . import router as router_module
35
+ from .service import InMemoryWebhookSubscriptions
36
+
37
+ try: # Optional dependency – only required when redis backends are selected.
38
+ from redis import Redis
39
+ except Exception: # pragma: no cover - redis is optional in most test runs.
40
+ Redis = None # type: ignore
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ T = TypeVar("T")
46
+
47
+
48
+ class _Factory(Protocol[T]):
49
+ def __call__(self) -> T:
50
+ ...
51
+
52
+
53
+ class RedisOutboxStore(OutboxStore):
54
+ """Minimal Redis-backed outbox implementation used by :func:`add_webhooks`.
55
+
56
+ The implementation is intentionally lightweight – it keeps message payloads
57
+ in Redis hashes and a FIFO list of message identifiers. It fulfils the
58
+ contract expected by :func:`make_outbox_tick` while remaining simple enough
59
+ for environments where a fully fledged SQL implementation is unavailable.
60
+ """
61
+
62
+ def __init__(self, client: "Redis", *, prefix: str = "webhooks:outbox"):
63
+ if Redis is None: # pragma: no cover - defensive guard
64
+ raise RuntimeError("redis-py is required for RedisOutboxStore")
65
+ self._client = client
66
+ self._prefix = prefix.rstrip(":")
67
+
68
+ # Redis key helpers -------------------------------------------------
69
+ @property
70
+ def _seq_key(self) -> str:
71
+ return f"{self._prefix}:seq"
72
+
73
+ @property
74
+ def _queue_key(self) -> str:
75
+ return f"{self._prefix}:queue"
76
+
77
+ def _msg_key(self, msg_id: int) -> str:
78
+ return f"{self._prefix}:msg:{msg_id}"
79
+
80
+ # Protocol methods --------------------------------------------------
81
+ def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
82
+ msg_id = int(self._client.incr(self._seq_key))
83
+ created_at = datetime.now(timezone.utc)
84
+ record = {
85
+ "id": msg_id,
86
+ "topic": topic,
87
+ "payload": json.dumps(payload),
88
+ "created_at": created_at.isoformat(),
89
+ "attempts": 0,
90
+ "processed_at": "",
91
+ }
92
+ self._client.hset(self._msg_key(msg_id), mapping=record)
93
+ self._client.rpush(self._queue_key, msg_id)
94
+ return OutboxMessage(id=msg_id, topic=topic, payload=payload, created_at=created_at)
95
+
96
+ def fetch_next(
97
+ self, *, topics: list[str] | tuple[str, ...] | set[str] | None = None
98
+ ) -> OutboxMessage | None:
99
+ allowed = set(topics) if topics else None
100
+ ids = self._client.lrange(self._queue_key, 0, -1)
101
+ for raw_id in ids:
102
+ msg_id = int(raw_id)
103
+ msg = self._client.hgetall(self._msg_key(msg_id))
104
+ if not msg:
105
+ continue
106
+ topic = msg.get(b"topic")
107
+ if topic is None:
108
+ continue
109
+ topic_str = topic.decode()
110
+ if allowed is not None and topic_str not in allowed:
111
+ continue
112
+ attempts = int(msg.get(b"attempts", 0))
113
+ processed_raw = msg.get(b"processed_at") or b""
114
+ if processed_raw:
115
+ continue
116
+ if attempts > 0:
117
+ continue
118
+ payload_raw = msg.get(b"payload") or b"{}"
119
+ payload = json.loads(payload_raw.decode())
120
+ created_raw = msg.get(b"created_at") or ""
121
+ created_at = (
122
+ datetime.fromisoformat(created_raw.decode()) if created_raw else datetime.now(timezone.utc)
123
+ )
124
+ return OutboxMessage(
125
+ id=msg_id,
126
+ topic=topic_str,
127
+ payload=payload,
128
+ created_at=created_at,
129
+ attempts=attempts,
130
+ )
131
+ return None
132
+
133
+ def mark_processed(self, msg_id: int) -> None:
134
+ key = self._msg_key(msg_id)
135
+ if not self._client.exists(key):
136
+ return
137
+ self._client.hset(key, "processed_at", datetime.now(timezone.utc).isoformat())
138
+
139
+ def mark_failed(self, msg_id: int) -> None:
140
+ key = self._msg_key(msg_id)
141
+ self._client.hincrby(key, "attempts", 1)
142
+
143
+
144
+ class RedisInboxStore(InboxStore):
145
+ """Lightweight Redis dedupe store for webhook deliveries."""
146
+
147
+ def __init__(self, client: "Redis", *, prefix: str = "webhooks:inbox"):
148
+ if Redis is None: # pragma: no cover - defensive guard
149
+ raise RuntimeError("redis-py is required for RedisInboxStore")
150
+ self._client = client
151
+ self._prefix = prefix.rstrip(":")
152
+
153
+ def _key(self, key: str) -> str:
154
+ return f"{self._prefix}:{key}"
155
+
156
+ def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
157
+ return bool(self._client.set(self._key(key), 1, nx=True, ex=ttl_seconds))
158
+
159
+ def purge_expired(self) -> int:
160
+ # Redis takes care of expirations. We return 0 to satisfy the interface.
161
+ return 0
162
+
163
+ def is_marked(self, key: str) -> bool:
164
+ return bool(self._client.exists(self._key(key)))
165
+
166
+
167
+ def _is_factory(obj: Any) -> bool:
168
+ return callable(obj) and not isinstance(obj, (str, bytes, bytearray))
169
+
170
+
171
+ def _resolve_value(value: T | _Factory[T] | None, default_factory: _Factory[T]) -> T:
172
+ if value is None:
173
+ return default_factory()
174
+ if _is_factory(value):
175
+ return value() # type: ignore[return-value]
176
+ return value # type: ignore[return-value]
177
+
178
+
179
+ def _build_redis_client(env: Mapping[str, str]) -> "Redis" | None:
180
+ if Redis is None:
181
+ logger.warning("Redis backend requested but redis-py is not installed; falling back to in-memory stores")
182
+ return None
183
+ url = env.get("REDIS_URL", "redis://localhost:6379/0")
184
+ return Redis.from_url(url)
185
+
186
+
187
+ def _default_outbox(env: Mapping[str, str]) -> OutboxStore:
188
+ backend = (env.get("WEBHOOKS_OUTBOX") or "memory").lower()
189
+ if backend == "redis":
190
+ client = _build_redis_client(env)
191
+ if client is not None:
192
+ logger.info("Using Redis outbox store for webhooks")
193
+ return RedisOutboxStore(client)
194
+ elif backend == "sql": # pragma: no cover - SQL backend is currently a placeholder
195
+ logger.warning(
196
+ "WEBHOOKS_OUTBOX=sql specified but SQL backend is not implemented; falling back to in-memory store"
197
+ )
198
+ return InMemoryOutboxStore()
199
+
200
+
201
+ def _default_inbox(env: Mapping[str, str]) -> InboxStore:
202
+ backend = (env.get("WEBHOOKS_INBOX") or "memory").lower()
203
+ if backend == "redis":
204
+ client = _build_redis_client(env)
205
+ if client is not None:
206
+ logger.info("Using Redis inbox store for webhooks")
207
+ return RedisInboxStore(client)
208
+ return InMemoryInboxStore()
209
+
210
+
211
+ def _default_subscriptions() -> InMemoryWebhookSubscriptions:
212
+ return InMemoryWebhookSubscriptions()
213
+
214
+
215
+ def _subscription_lookup(subs: InMemoryWebhookSubscriptions) -> tuple[Callable[[str], str], Callable[[str], str]]:
216
+ def _get_url(topic: str) -> str:
217
+ items = subs.get_for_topic(topic)
218
+ if not items:
219
+ raise LookupError(f"No webhook subscription for topic '{topic}'")
220
+ return items[0].url
221
+
222
+ def _get_secret(topic: str) -> str:
223
+ items = subs.get_for_topic(topic)
224
+ if not items:
225
+ raise LookupError(f"No webhook subscription for topic '{topic}'")
226
+ return items[0].secret
227
+
228
+ return _get_url, _get_secret
229
+
230
+
231
+ @overload
232
+ def add_webhooks(
233
+ app: FastAPI,
234
+ *,
235
+ outbox: OutboxStore | _Factory[OutboxStore] | None = ...,
236
+ inbox: InboxStore | _Factory[InboxStore] | None = ...,
237
+ subscriptions: InMemoryWebhookSubscriptions | _Factory[InMemoryWebhookSubscriptions] | None = ...,
238
+ queue: JobQueue | None = ...,
239
+ scheduler: InMemoryScheduler | None = ...,
240
+ schedule_tick: bool = ...,
241
+ env: Mapping[str, str] = ...,
242
+ ) -> None:
243
+ ...
244
+
245
+
246
+ def add_webhooks(
247
+ app: FastAPI,
248
+ *,
249
+ outbox: OutboxStore | _Factory[OutboxStore] | None = None,
250
+ inbox: InboxStore | _Factory[InboxStore] | None = None,
251
+ subscriptions: InMemoryWebhookSubscriptions | _Factory[InMemoryWebhookSubscriptions] | None = None,
252
+ queue: JobQueue | None = None,
253
+ scheduler: InMemoryScheduler | None = None,
254
+ schedule_tick: bool = True,
255
+ env: Mapping[str, str] = os.environ,
256
+ ) -> None:
257
+ """Attach the shared webhooks router and stores to a FastAPI app.
258
+
259
+ Parameters
260
+ ----------
261
+ app:
262
+ The FastAPI application to configure.
263
+ outbox / inbox / subscriptions:
264
+ Optional instances or callables returning instances to use. When left
265
+ as ``None`` the helper chooses sensible defaults: in-memory stores for
266
+ local runs or Redis-backed stores when ``WEBHOOKS_OUTBOX`` /
267
+ ``WEBHOOKS_INBOX`` are set to ``"redis"`` and ``REDIS_URL`` is
268
+ available.
269
+ queue / scheduler:
270
+ Provide these when you want :func:`make_outbox_tick` and the webhook
271
+ delivery handler registered for you. The tick task is scheduled every
272
+ second by default; disable that registration by passing
273
+ ``schedule_tick=False``.
274
+ env:
275
+ Mapping used to resolve environment-driven defaults. Defaults to
276
+ :data:`os.environ` so standard environment variables Just Work.
277
+
278
+ Side effects
279
+ ------------
280
+ * ``app.include_router`` is invoked for :mod:`svc_infra.webhooks.router`.
281
+ * ``app.dependency_overrides`` is populated so router dependencies reuse the
282
+ shared stores.
283
+ * References are stored on ``app.state`` for further customisation:
284
+ ``webhooks_outbox``, ``webhooks_inbox``, ``webhooks_subscriptions``,
285
+ ``webhooks_outbox_tick`` (when a queue is present) and
286
+ ``webhooks_delivery_handler`` (when queue+inbox are present).
287
+ """
288
+
289
+ resolved_outbox = _resolve_value(outbox, lambda: _default_outbox(env))
290
+ resolved_inbox = _resolve_value(inbox, lambda: _default_inbox(env))
291
+ resolved_subs = _resolve_value(subscriptions, _default_subscriptions)
292
+
293
+ app.state.webhooks_outbox = resolved_outbox
294
+ app.state.webhooks_inbox = resolved_inbox
295
+ app.state.webhooks_subscriptions = resolved_subs
296
+
297
+ app.include_router(router_module.router)
298
+
299
+ app.dependency_overrides[router_module.get_outbox] = lambda: resolved_outbox
300
+ app.dependency_overrides[router_module.get_subs] = lambda: resolved_subs
301
+
302
+ outbox_tick = None
303
+ if queue is not None:
304
+ outbox_tick = make_outbox_tick(resolved_outbox, queue)
305
+ app.state.webhooks_outbox_tick = outbox_tick
306
+ if scheduler is not None and schedule_tick:
307
+ scheduler.add_task("webhooks.outbox", 1, outbox_tick)
308
+
309
+ url_lookup, secret_lookup = _subscription_lookup(resolved_subs)
310
+ handler = make_webhook_handler(
311
+ outbox=resolved_outbox,
312
+ inbox=resolved_inbox,
313
+ get_webhook_url_for_topic=url_lookup,
314
+ get_secret_for_topic=secret_lookup,
315
+ )
316
+ app.state.webhooks_delivery_handler = handler
317
+ elif scheduler is not None and schedule_tick:
318
+ logger.warning("Scheduler provided without queue; skipping outbox tick registration")
319
+
320
+
321
+ __all__ = ["add_webhooks"]
322
+
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, Sequence
4
+
5
+ from fastapi import HTTPException, Request, status
6
+
7
+ from .signing import verify, verify_any
8
+
9
+
10
+ def require_signature(
11
+ secrets_provider: Callable[[], str | Sequence[str]],
12
+ *,
13
+ header_name: str = "X-Signature",
14
+ ):
15
+ async def _dep(request: Request):
16
+ sig = request.headers.get(header_name)
17
+ if not sig:
18
+ raise HTTPException(
19
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="missing signature"
20
+ )
21
+ try:
22
+ body = await request.json()
23
+ except Exception:
24
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid JSON body")
25
+ secrets = secrets_provider()
26
+ ok = False
27
+ if isinstance(secrets, str):
28
+ ok = verify(secrets, body, sig)
29
+ else:
30
+ ok = verify_any(secrets, body, sig)
31
+ if not ok:
32
+ raise HTTPException(
33
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid signature"
34
+ )
35
+ return body
36
+
37
+ return _dep
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+
7
+ from svc_infra.db.outbox import InMemoryOutboxStore, OutboxStore
8
+
9
+ from .service import InMemoryWebhookSubscriptions, WebhookService
10
+
11
+ router = APIRouter(prefix="/_webhooks", tags=["webhooks"])
12
+
13
+
14
+ def get_outbox() -> OutboxStore:
15
+ # For now expose an in-memory default. Apps can override via DI.
16
+ # In production, provide a proper store through dependency override.
17
+ return InMemoryOutboxStore()
18
+
19
+
20
+ def get_subs() -> InMemoryWebhookSubscriptions:
21
+ return InMemoryWebhookSubscriptions()
22
+
23
+
24
+ def get_service(
25
+ outbox: OutboxStore = Depends(get_outbox),
26
+ subs: InMemoryWebhookSubscriptions = Depends(get_subs),
27
+ ) -> WebhookService:
28
+ return WebhookService(outbox=outbox, subs=subs)
29
+
30
+
31
+ @router.post("/subscriptions")
32
+ def add_subscription(
33
+ body: Dict[str, Any],
34
+ subs: InMemoryWebhookSubscriptions = Depends(get_subs),
35
+ ):
36
+ topic = body.get("topic")
37
+ url = body.get("url")
38
+ secret = body.get("secret")
39
+ if not topic or not url or not secret:
40
+ raise HTTPException(status_code=400, detail="Missing topic/url/secret")
41
+ subs.add(topic, url, secret)
42
+ return {"ok": True}
43
+
44
+
45
+ @router.post("/test-fire")
46
+ def test_fire(
47
+ body: Dict[str, Any],
48
+ svc: WebhookService = Depends(get_service),
49
+ ):
50
+ topic = body.get("topic")
51
+ payload = body.get("payload") or {}
52
+ if not topic:
53
+ raise HTTPException(status_code=400, detail="Missing topic")
54
+ outbox_id = svc.publish(topic, payload)
55
+ return {"ok": True, "outbox_id": outbox_id}
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Dict, List
6
+
7
+ from uuid import uuid4
8
+
9
+ from svc_infra.db.outbox import OutboxStore
10
+
11
+
12
+ @dataclass
13
+ class WebhookSubscription:
14
+ topic: str
15
+ url: str
16
+ secret: str
17
+ id: str = field(default_factory=lambda: uuid4().hex)
18
+
19
+
20
+ class InMemoryWebhookSubscriptions:
21
+ def __init__(self):
22
+ self._subs: Dict[str, List[WebhookSubscription]] = {}
23
+
24
+ def add(self, topic: str, url: str, secret: str) -> None:
25
+ self._subs.setdefault(topic, []).append(WebhookSubscription(topic, url, secret))
26
+
27
+ def get_for_topic(self, topic: str) -> List[WebhookSubscription]:
28
+ return list(self._subs.get(topic, []))
29
+
30
+
31
+ class WebhookService:
32
+ def __init__(self, outbox: OutboxStore, subs: InMemoryWebhookSubscriptions):
33
+ self._outbox = outbox
34
+ self._subs = subs
35
+
36
+ def publish(self, topic: str, payload: Dict, *, version: int = 1) -> int:
37
+ created_at = datetime.now(timezone.utc).isoformat()
38
+ base_event = {
39
+ "topic": topic,
40
+ "payload": payload,
41
+ "version": version,
42
+ "created_at": created_at,
43
+ }
44
+ # For each subscription, enqueue an outbox message with subscriber identity
45
+ last_id = 0
46
+ for sub in self._subs.get_for_topic(topic):
47
+ event = dict(base_event)
48
+ msg_payload = {
49
+ "event": event,
50
+ "subscription": {
51
+ "id": sub.id,
52
+ "topic": sub.topic,
53
+ "url": sub.url,
54
+ "secret": sub.secret,
55
+ },
56
+ }
57
+ msg = self._outbox.enqueue(topic, msg_payload)
58
+ last_id = msg.id
59
+ return last_id
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ from typing import Dict, Iterable
7
+
8
+
9
+ def canonical_body(payload: Dict) -> bytes:
10
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
11
+
12
+
13
+ def sign(secret: str, payload: Dict) -> str:
14
+ body = canonical_body(payload)
15
+ return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
16
+
17
+
18
+ def verify(secret: str, payload: Dict, signature: str) -> bool:
19
+ expected = sign(secret, payload)
20
+ try:
21
+ return hmac.compare_digest(expected, signature)
22
+ except Exception:
23
+ return False
24
+
25
+
26
+ def verify_any(secrets: Iterable[str], payload: Dict, signature: str) -> bool:
27
+ for s in secrets:
28
+ if verify(s, payload, signature):
29
+ return True
30
+ return False
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.3
2
+ Name: svc-infra
3
+ Version: 0.1.599
4
+ Summary: Infrastructure for building and deploying prod-ready services
5
+ License: MIT
6
+ Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
7
+ Author: Ali Khatami
8
+ Author-email: aliikhatami94@gmail.com
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Typing :: Typed
20
+ Provides-Extra: duckdb
21
+ Provides-Extra: metrics
22
+ Provides-Extra: mssql
23
+ Provides-Extra: mysql
24
+ Provides-Extra: pg
25
+ Provides-Extra: pg2
26
+ Provides-Extra: redshift
27
+ Provides-Extra: snowflake
28
+ Provides-Extra: sqlite
29
+ Requires-Dist: adyen (>=13.4.0,<14.0.0)
30
+ Requires-Dist: ai-infra (>=0.1.63,<0.2.0)
31
+ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0) ; extra == "sqlite"
32
+ Requires-Dist: alembic (>=1.13.2,<2.0.0)
33
+ Requires-Dist: asyncpg (>=0.30.0,<0.31.0) ; extra == "pg"
34
+ Requires-Dist: authlib (>=1.6.2,<2.0.0)
35
+ Requires-Dist: cashews[redis] (>=7.4.1,<8.0.0)
36
+ Requires-Dist: duckdb (>=1.1.3,<2.0.0) ; extra == "duckdb"
37
+ Requires-Dist: email-validator (>=2.2.0,<3.0.0)
38
+ Requires-Dist: fastapi (>=0.116.1,<0.117.0)
39
+ Requires-Dist: fastapi-users-db-sqlalchemy (>=7.0.0,<8.0.0)
40
+ Requires-Dist: fastapi-users[oauth] (>=14.0.1,<15.0.0)
41
+ Requires-Dist: greenlet (>=3,<4)
42
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
43
+ Requires-Dist: httpx-oauth (>=0.16.1,<0.17.0)
44
+ Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
45
+ Requires-Dist: mcp (>=1.13.0,<2.0.0)
46
+ Requires-Dist: motor (>=3.7.1,<4.0.0)
47
+ Requires-Dist: mysqlclient (>=2.2.4,<3.0.0) ; extra == "mysql"
48
+ Requires-Dist: opentelemetry-exporter-otlp (>=1.36.0,<2.0.0)
49
+ Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.57b0,<0.58)
50
+ Requires-Dist: opentelemetry-instrumentation-httpx (>=0.57b0,<0.58)
51
+ Requires-Dist: opentelemetry-instrumentation-requests (>=0.57b0,<0.58)
52
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy (>=0.57b0,<0.58)
53
+ Requires-Dist: opentelemetry-propagator-b3 (>=1.36.0,<2.0.0)
54
+ Requires-Dist: opentelemetry-sdk (>=1.36.0,<2.0.0)
55
+ Requires-Dist: passlib[bcrypt] (>=1.7.4,<2.0.0)
56
+ Requires-Dist: pre-commit (>=4.3.0,<5.0.0)
57
+ Requires-Dist: prometheus-client (>=0.22.1,<0.23.0) ; extra == "metrics"
58
+ Requires-Dist: psycopg2-binary (>=2.9.10,<3.0.0) ; extra == "pg2"
59
+ Requires-Dist: psycopg[binary] (>=3.2.10,<4.0.0) ; extra == "pg"
60
+ Requires-Dist: pydantic-settings (>=2.10.1,<3.0.0)
61
+ Requires-Dist: pymysql (>=1.1.1,<2.0.0) ; extra == "mysql"
62
+ Requires-Dist: pyodbc (>=5.1.0,<6.0.0) ; extra == "mssql"
63
+ Requires-Dist: pyotp (>=2.9.0,<3.0.0)
64
+ Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
65
+ Requires-Dist: redis (>=6.4.0,<7.0.0)
66
+ Requires-Dist: redshift-connector (>=2.0.918,<3.0.0) ; extra == "redshift"
67
+ Requires-Dist: snowflake-connector-python (>=3.12.0,<4.0.0) ; extra == "snowflake"
68
+ Requires-Dist: sqlalchemy[asyncio] (>=2.0.43,<3.0.0)
69
+ Requires-Dist: stripe (>=13.0.1,<14.0.0)
70
+ Requires-Dist: typer (>=0.16.1,<0.17.0)
71
+ Project-URL: Documentation, https://github.com/your-org/svc-infra#readme
72
+ Project-URL: Homepage, https://github.com/your-org/svc-infra
73
+ Project-URL: Issues, https://github.com/your-org/svc-infra/issues
74
+ Project-URL: Repository, https://github.com/your-org/svc-infra
75
+ Description-Content-Type: text/markdown
76
+
77
+ # svc-infra
78
+
79
+ [![PyPI](https://img.shields.io/pypi/v/svc-infra.svg)](https://pypi.org/project/svc-infra/)
80
+ [![Docs](https://img.shields.io/badge/docs-reference-blue)](docs/)
81
+
82
+ svc-infra packages the shared building blocks we use to ship production FastAPI services fast—HTTP APIs with secure auth, durable persistence, background execution, cache, observability, and webhook plumbing that all share the same batteries-included defaults.
83
+
84
+ ## Helper index
85
+
86
+ | Helper | What it covers | Guide |
87
+ | --- | --- | --- |
88
+ | API | FastAPI bootstrap, envelopes, middleware, docs wiring | [FastAPI guide](docs/api.md) |
89
+ | Auth | Sessions, OAuth/OIDC, MFA, SMTP delivery | [Auth settings](docs/auth.md) |
90
+ | Database | SQL + Mongo wiring, Alembic helpers, inbox/outbox patterns | [Database guide](docs/database.md) |
91
+ | Jobs | JobQueue, scheduler, CLI worker | [Jobs quickstart](docs/jobs.md) |
92
+ | Cache | cashews decorators, namespace management, TTL helpers | [Cache guide](docs/cache.md) |
93
+ | Observability | Prometheus middleware, Grafana automation, OTEL hooks | [Observability guide](docs/observability.md) |
94
+ | Webhooks | Subscription store, signing, retry worker | [Webhooks framework](docs/webhooks.md) |
95
+ | Security | Password policy, lockout, signed cookies, headers | [Security hardening](docs/security.md) |
96
+
97
+ ## Minimal FastAPI bootstrap
98
+
99
+ ```python
100
+ from fastapi import Depends
101
+ from svc_infra.api.fastapi.ease import easy_service_app
102
+ from svc_infra.api.fastapi.db.sql.add import add_sql_db
103
+ from svc_infra.cache import init_cache
104
+ from svc_infra.jobs.easy import easy_jobs
105
+ from svc_infra.webhooks.fastapi import require_signature
106
+
107
+ app = easy_service_app(name="Billing", release="1.2.3")
108
+ add_sql_db(app) # reads SQL_URL / DB_* envs
109
+ init_cache() # honors CACHE_PREFIX / CACHE_VERSION
110
+ queue, scheduler = easy_jobs() # switches via JOBS_DRIVER / REDIS_URL
111
+
112
+ @app.post("/webhooks/billing")
113
+ async def handle_webhook(payload = Depends(require_signature(lambda: ["current", "next"]))):
114
+ queue.enqueue("process-billing-webhook", payload)
115
+ return {"status": "queued"}
116
+ ```
117
+
118
+ ## Environment switches
119
+
120
+ - **API** – toggle logging/observability and docs exposure with `ENABLE_LOGGING`, `LOG_LEVEL`, `LOG_FORMAT`, `ENABLE_OBS`, `METRICS_PATH`, `OBS_SKIP_PATHS`, and `CORS_ALLOW_ORIGINS`. 【F:src/svc_infra/api/fastapi/ease.py†L67-L111】【F:src/svc_infra/api/fastapi/setup.py†L47-L88】
121
+ - **Auth** – configure JWT secrets, SMTP, cookies, and policy using the `AUTH_…` settings family (e.g., `AUTH_JWT__SECRET`, `AUTH_SMTP_HOST`, `AUTH_SESSION_COOKIE_SECURE`). 【F:src/svc_infra/api/fastapi/auth/settings.py†L23-L91】
122
+ - **Database** – set connection URLs or components via `SQL_URL`/`SQL_URL_FILE`, `DB_DIALECT`, `DB_HOST`, `DB_USER`, `DB_PASSWORD`, plus Mongo knobs like `MONGO_URL`, `MONGO_DB`, and `MONGO_URL_FILE`. 【F:src/svc_infra/api/fastapi/db/sql/add.py†L55-L114】【F:src/svc_infra/db/sql/utils.py†L85-L206】【F:src/svc_infra/db/nosql/mongo/settings.py†L9-L13】【F:src/svc_infra/db/nosql/utils.py†L56-L113】
123
+ - **Jobs** – choose the queue backend with `JOBS_DRIVER` and provide Redis via `REDIS_URL`; interval schedules can be declared with `JOBS_SCHEDULE_JSON`. 【F:src/svc_infra/jobs/easy.py†L11-L27】【F:docs/jobs.md†L11-L48】
124
+ - **Cache** – namespace keys and lifetimes through `CACHE_PREFIX`, `CACHE_VERSION`, and TTL overrides `CACHE_TTL_DEFAULT`, `CACHE_TTL_SHORT`, `CACHE_TTL_LONG`. 【F:src/svc_infra/cache/README.md†L20-L173】【F:src/svc_infra/cache/ttl.py†L26-L55】
125
+ - **Observability** – turn metrics on/off or adjust scrape paths with `ENABLE_OBS`, `METRICS_PATH`, `OBS_SKIP_PATHS`, and Prometheus/Grafana flags like `SVC_INFRA_DISABLE_PROMETHEUS`, `SVC_INFRA_RATE_WINDOW`, `SVC_INFRA_DASHBOARD_REFRESH`, `SVC_INFRA_DASHBOARD_RANGE`. 【F:src/svc_infra/api/fastapi/ease.py†L67-L111】【F:src/svc_infra/obs/metrics/asgi.py†L49-L206】【F:src/svc_infra/obs/cloud_dash.py†L85-L108】
126
+ - **Webhooks** – reuse the jobs envs (`JOBS_DRIVER`, `REDIS_URL`) for the delivery worker and queue configuration. 【F:docs/webhooks.md†L32-L53】
127
+ - **Security** – enforce password policy, MFA, and rotation with auth prefixes such as `AUTH_PASSWORD_MIN_LENGTH`, `AUTH_PASSWORD_REQUIRE_SYMBOL`, `AUTH_JWT__SECRET`, and `AUTH_JWT__OLD_SECRETS`. 【F:docs/security.md†L24-L70】
128
+
@@ -141,7 +141,7 @@ svc_infra/cli/foundation/runner.py,sha256=RbfjKwb3aHk1Y0MYU8xMpKRpIqRVMVr8GuL2ED
141
141
  svc_infra/cli/foundation/typer_bootstrap.py,sha256=KapgH1R-qON9FuYH1KYlVx_5sJvjmAGl25pB61XCpm4,985
142
142
  svc_infra/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
143
143
  svc_infra/db/crud_schema.py,sha256=-fv-Om1lHVt6lcNbie6A2kRcPex4SDByUPfks6SpmUc,2521
144
- svc_infra/db/inbox.py,sha256=07GHRGN3jCjGVgcjjVGeKKgXkmwvgtwSu_O1Cb1_9hA,1600
144
+ svc_infra/db/inbox.py,sha256=drxLRLHaMRrCDgo_8wj12do80wDh5ssHV6LGkaM98no,1996
145
145
  svc_infra/db/nosql/__init__.py,sha256=5ETPHk-KYUtc-efuGzDFQmWkT0xFtYy8YWOHobMZhvM,154
146
146
  svc_infra/db/nosql/base.py,sha256=p47VVpwWvGNkyWe5RDSmGaUFyZovcyNqirMqoHFQ4QU,230
147
147
  svc_infra/db/nosql/constants.py,sha256=Z9bJImxwb8D7vovASFegv8XMwaWcM28tsKJV2SjywXE,416
@@ -195,7 +195,7 @@ svc_infra/db/sql/utils.py,sha256=nzuDcDhnVNehx5Y9BZLgxw8fvpfYbxTfXQsgnznVf4w,328
195
195
  svc_infra/db/sql/versioning.py,sha256=okZu2ad5RAFXNLXJgGpcQvZ5bc6gPjRWzwiBT0rEJJw,400
196
196
  svc_infra/db/utils.py,sha256=aTD49VJSEu319kIWJ1uijUoP51co4lNJ3S0_tvuyGio,802
197
197
  svc_infra/jobs/builtins/outbox_processor.py,sha256=VZoehNyjdaV_MmV74WMcbZR6z9E3VFMtZC-pxEwK0x0,1247
198
- svc_infra/jobs/builtins/webhook_delivery.py,sha256=6D_nmwpPOyrkzx4MM2vrpA0JKGfWbWo4BBavYEXhpDQ,1894
198
+ svc_infra/jobs/builtins/webhook_delivery.py,sha256=z_cl6YKwnduGjGaB8ZoUpKhFcEAhUZqqBma8v2FO1so,2982
199
199
  svc_infra/jobs/easy.py,sha256=eix-OxWeE3vdkY3GGNoYM0GAyOxc928SpiSzMkr9k0A,977
200
200
  svc_infra/jobs/loader.py,sha256=LFO6gOacj6rT698vkDg0YfcHDRTue4zus3Nl9QrS5R0,1164
201
201
  svc_infra/jobs/queue.py,sha256=PS5f4CJm5_K7icojTxZOwC6uKw3O2M-jE111u85ySbA,2288
@@ -204,7 +204,7 @@ svc_infra/jobs/scheduler.py,sha256=dTUEEyEuTVHNmJT8wPdMu4YjnTN7R_YW67gtCKpqC7M,1
204
204
  svc_infra/jobs/worker.py,sha256=T2A575_mnieJHPOYU_FseubLA_HQf9pB4CkRgzRJBHU,694
205
205
  svc_infra/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
206
206
  svc_infra/mcp/svc_infra_mcp.py,sha256=NmBY7AM3_pnHAumE-eM5Njr8kpb7Gh1-fjcZAEammiI,1927
207
- svc_infra/obs/README.md,sha256=wOABJUOhuj0ftGt24ZfuChlFNJTYvYq4KM_rcRIdWRU,7884
207
+ svc_infra/obs/README.md,sha256=pmd6AyFZW3GCCi0sr3uTHrPj5KgAI8rrXw8QPkrf1R8,8021
208
208
  svc_infra/obs/__init__.py,sha256=t5DgkiuuhHnfAHChzYqCI1-Fpr68iQ0A1nHOLFIlAuM,75
209
209
  svc_infra/obs/add.py,sha256=j8Nsv6k7mGM7tGFIoCxgSpFNV93G_WmtSbCIBohHRT4,2026
210
210
  svc_infra/obs/cloud_dash.py,sha256=1rg6NO9kjhN3zCugfBqDxkTN5nQqjQRC5ye2gFxb6g4,4329
@@ -250,6 +250,7 @@ svc_infra/obs/templates/sidecars/railway/README.md,sha256=3tFBJPKvmxjg3FjtfGYLwB
250
250
  svc_infra/obs/templates/sidecars/railway/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
251
251
  svc_infra/obs/templates/sidecars/railway/agent.yaml,sha256=hYv35yG92XEP_4joMFmMcVTD-4fG_zHitmChjreUJh4,516
252
252
  svc_infra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
253
+ svc_infra/security/add.py,sha256=VrGPDUSXnGX3caNXi4BbqNllSQ5YGsZzBtKESWXDJzA,6114
253
254
  svc_infra/security/audit.py,sha256=r_OrXAz5uIa2o5nVD-8lsWqzggRGDKfp2sWd8URlz-E,4355
254
255
  svc_infra/security/audit_service.py,sha256=Xd5V7Iz6PS4YpxmLyJypnaqr8poaaleKwAI2uFF7y1A,2351
255
256
  svc_infra/security/headers.py,sha256=1VkAe-IWzPYfHxeZAnv9QwtzD7fP9NBTJw3mflW17bQ,1461
@@ -263,7 +264,13 @@ svc_infra/security/permissions.py,sha256=fQm7-OcJJkWsScDcjS2gwmqaW93zQqltaHRl6bv
263
264
  svc_infra/security/session.py,sha256=JkClqoZ-Moo9yqHzCREXMVSpzyjbn2Zh6zCjtWO93Ik,2848
264
265
  svc_infra/security/signed_cookies.py,sha256=2t61BgjsBaTzU46bt7IUJo7lwDRE9_eS4vmAQXJ8mlY,2219
265
266
  svc_infra/utils.py,sha256=VX1yjTx61-YvAymyRhGy18DhybiVdPddiYD_FlKTbJU,952
266
- svc_infra-0.1.597.dist-info/METADATA,sha256=GN5xri15URUe0_f-BESdwDp7BzNL5DBj5penjmrOcuw,3527
267
- svc_infra-0.1.597.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
268
- svc_infra-0.1.597.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
269
- svc_infra-0.1.597.dist-info/RECORD,,
267
+ svc_infra/webhooks/__init__.py,sha256=fvPhbFoS6whoT67DWp43pL3m1o-et104vwqxunCUAPA,398
268
+ svc_infra/webhooks/add.py,sha256=u9Spfwg0ztQmXg7uXP1sZ9-_qwnagnW4UnV9HvQtPwc,12191
269
+ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA,1101
270
+ svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
271
+ svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
272
+ svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
273
+ svc_infra-0.1.599.dist-info/METADATA,sha256=dIULp2nBSVBybFpHOi0m0LEZRZe0dYUTYZPJYve8i1w,7839
274
+ svc_infra-0.1.599.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
275
+ svc_infra-0.1.599.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
276
+ svc_infra-0.1.599.dist-info/RECORD,,
@@ -1,80 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: svc-infra
3
- Version: 0.1.597
4
- Summary: Infrastructure for building and deploying prod-ready services
5
- License: MIT
6
- Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
7
- Author: Ali Khatami
8
- Author-email: aliikhatami94@gmail.com
9
- Requires-Python: >=3.11,<4.0
10
- Classifier: Development Status :: 4 - Beta
11
- Classifier: Framework :: FastAPI
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.11
16
- Classifier: Programming Language :: Python :: 3.12
17
- Classifier: Programming Language :: Python :: 3.13
18
- Classifier: Programming Language :: Python :: 3 :: Only
19
- Classifier: Typing :: Typed
20
- Provides-Extra: duckdb
21
- Provides-Extra: metrics
22
- Provides-Extra: mssql
23
- Provides-Extra: mysql
24
- Provides-Extra: pg
25
- Provides-Extra: pg2
26
- Provides-Extra: redshift
27
- Provides-Extra: snowflake
28
- Provides-Extra: sqlite
29
- Requires-Dist: adyen (>=13.4.0,<14.0.0)
30
- Requires-Dist: ai-infra (>=0.1.63,<0.2.0)
31
- Requires-Dist: aiosqlite (>=0.20.0,<0.21.0) ; extra == "sqlite"
32
- Requires-Dist: alembic (>=1.13.2,<2.0.0)
33
- Requires-Dist: asyncpg (>=0.30.0,<0.31.0) ; extra == "pg"
34
- Requires-Dist: authlib (>=1.6.2,<2.0.0)
35
- Requires-Dist: cashews[redis] (>=7.4.1,<8.0.0)
36
- Requires-Dist: duckdb (>=1.1.3,<2.0.0) ; extra == "duckdb"
37
- Requires-Dist: email-validator (>=2.2.0,<3.0.0)
38
- Requires-Dist: fastapi (>=0.116.1,<0.117.0)
39
- Requires-Dist: fastapi-users-db-sqlalchemy (>=7.0.0,<8.0.0)
40
- Requires-Dist: fastapi-users[oauth] (>=14.0.1,<15.0.0)
41
- Requires-Dist: greenlet (>=3,<4)
42
- Requires-Dist: httpx (>=0.28.1,<0.29.0)
43
- Requires-Dist: httpx-oauth (>=0.16.1,<0.17.0)
44
- Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
45
- Requires-Dist: mcp (>=1.13.0,<2.0.0)
46
- Requires-Dist: motor (>=3.7.1,<4.0.0)
47
- Requires-Dist: mysqlclient (>=2.2.4,<3.0.0) ; extra == "mysql"
48
- Requires-Dist: opentelemetry-exporter-otlp (>=1.36.0,<2.0.0)
49
- Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.57b0,<0.58)
50
- Requires-Dist: opentelemetry-instrumentation-httpx (>=0.57b0,<0.58)
51
- Requires-Dist: opentelemetry-instrumentation-requests (>=0.57b0,<0.58)
52
- Requires-Dist: opentelemetry-instrumentation-sqlalchemy (>=0.57b0,<0.58)
53
- Requires-Dist: opentelemetry-propagator-b3 (>=1.36.0,<2.0.0)
54
- Requires-Dist: opentelemetry-sdk (>=1.36.0,<2.0.0)
55
- Requires-Dist: passlib[bcrypt] (>=1.7.4,<2.0.0)
56
- Requires-Dist: pre-commit (>=4.3.0,<5.0.0)
57
- Requires-Dist: prometheus-client (>=0.22.1,<0.23.0) ; extra == "metrics"
58
- Requires-Dist: psycopg2-binary (>=2.9.10,<3.0.0) ; extra == "pg2"
59
- Requires-Dist: psycopg[binary] (>=3.2.10,<4.0.0) ; extra == "pg"
60
- Requires-Dist: pydantic-settings (>=2.10.1,<3.0.0)
61
- Requires-Dist: pymysql (>=1.1.1,<2.0.0) ; extra == "mysql"
62
- Requires-Dist: pyodbc (>=5.1.0,<6.0.0) ; extra == "mssql"
63
- Requires-Dist: pyotp (>=2.9.0,<3.0.0)
64
- Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
65
- Requires-Dist: redis (>=6.4.0,<7.0.0)
66
- Requires-Dist: redshift-connector (>=2.0.918,<3.0.0) ; extra == "redshift"
67
- Requires-Dist: snowflake-connector-python (>=3.12.0,<4.0.0) ; extra == "snowflake"
68
- Requires-Dist: sqlalchemy[asyncio] (>=2.0.43,<3.0.0)
69
- Requires-Dist: stripe (>=13.0.1,<14.0.0)
70
- Requires-Dist: typer (>=0.16.1,<0.17.0)
71
- Project-URL: Documentation, https://github.com/your-org/svc-infra#readme
72
- Project-URL: Homepage, https://github.com/your-org/svc-infra
73
- Project-URL: Issues, https://github.com/your-org/svc-infra/issues
74
- Project-URL: Repository, https://github.com/your-org/svc-infra
75
- Description-Content-Type: text/markdown
76
-
77
- # svc-infra
78
-
79
- Will add description later.
80
-