svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from sqlalchemy import JSON, DateTime, Index, Numeric, String, UniqueConstraint, text
7
+ from sqlalchemy.orm import Mapped, mapped_column
8
+
9
+ from svc_infra.db.sql.base import ModelBase
10
+
11
+ TENANT_ID_LEN = 64
12
+
13
+
14
+ class UsageEvent(ModelBase):
15
+ __tablename__ = "billing_usage_events"
16
+
17
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
18
+ tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
19
+ metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
20
+ amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
21
+ at_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
22
+ idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
23
+ metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
24
+ created_at: Mapped[datetime] = mapped_column(
25
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
26
+ )
27
+
28
+ __table_args__ = (
29
+ UniqueConstraint("tenant_id", "metric", "idempotency_key", name="uq_usage_idem"),
30
+ Index("ix_usage_tenant_metric_ts", "tenant_id", "metric", "at_ts"),
31
+ )
32
+
33
+
34
+ class UsageAggregate(ModelBase):
35
+ __tablename__ = "billing_usage_aggregates"
36
+
37
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
38
+ tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
39
+ metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
40
+ period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
41
+ granularity: Mapped[str] = mapped_column(String(8), nullable=False) # hour|day|month
42
+ total: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
43
+ updated_at: Mapped[datetime] = mapped_column(
44
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
45
+ )
46
+
47
+ __table_args__ = (
48
+ UniqueConstraint("tenant_id", "metric", "period_start", "granularity", name="uq_usage_agg"),
49
+ )
50
+
51
+
52
+ class Plan(ModelBase):
53
+ __tablename__ = "billing_plans"
54
+
55
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
56
+ key: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
57
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
58
+ description: Mapped[Optional[str]] = mapped_column(String(255))
59
+ created_at: Mapped[datetime] = mapped_column(
60
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
61
+ )
62
+
63
+
64
+ class PlanEntitlement(ModelBase):
65
+ __tablename__ = "billing_plan_entitlements"
66
+
67
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
68
+ plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
69
+ key: Mapped[str] = mapped_column(String(64), nullable=False)
70
+ limit_per_window: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
71
+ window: Mapped[str] = mapped_column(String(8), nullable=False) # day|month
72
+ created_at: Mapped[datetime] = mapped_column(
73
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
74
+ )
75
+
76
+
77
+ class Subscription(ModelBase):
78
+ __tablename__ = "billing_subscriptions"
79
+
80
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
81
+ tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
82
+ plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
83
+ effective_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
84
+ ended_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
85
+ created_at: Mapped[datetime] = mapped_column(
86
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
87
+ )
88
+
89
+
90
+ class Price(ModelBase):
91
+ __tablename__ = "billing_prices"
92
+
93
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
94
+ key: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
95
+ currency: Mapped[str] = mapped_column(String(8), nullable=False)
96
+ unit_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False) # minor units
97
+ metric: Mapped[Optional[str]] = mapped_column(String(64)) # null for fixed recurring
98
+ recurring_interval: Mapped[Optional[str]] = mapped_column(String(8)) # month|year
99
+ created_at: Mapped[datetime] = mapped_column(
100
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
101
+ )
102
+
103
+
104
+ class Invoice(ModelBase):
105
+ __tablename__ = "billing_invoices"
106
+
107
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
108
+ tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
109
+ period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
110
+ period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
111
+ status: Mapped[str] = mapped_column(String(16), index=True, nullable=False)
112
+ total_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
113
+ currency: Mapped[str] = mapped_column(String(8), nullable=False)
114
+ provider_invoice_id: Mapped[Optional[str]] = mapped_column(String(128), index=True)
115
+ created_at: Mapped[datetime] = mapped_column(
116
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
117
+ )
118
+
119
+
120
+ class InvoiceLine(ModelBase):
121
+ __tablename__ = "billing_invoice_lines"
122
+
123
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
124
+ invoice_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
125
+ price_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
126
+ metric: Mapped[Optional[str]] = mapped_column(String(64))
127
+ quantity: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
128
+ amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
129
+ created_at: Mapped[datetime] = mapped_column(
130
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
131
+ )
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Annotated, Optional
5
+
6
+ from fastapi import Depends, HTTPException, status
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
11
+ from svc_infra.api.fastapi.tenancy.context import TenantId
12
+
13
+ from .models import PlanEntitlement, Subscription, UsageAggregate
14
+
15
+
16
+ async def _current_subscription(session: AsyncSession, tenant_id: str) -> Optional[Subscription]:
17
+ now = datetime.now(tz=timezone.utc)
18
+ row = (
19
+ (
20
+ await session.execute(
21
+ select(Subscription)
22
+ .where(Subscription.tenant_id == tenant_id)
23
+ .order_by(Subscription.effective_at.desc())
24
+ )
25
+ )
26
+ .scalars()
27
+ .first()
28
+ )
29
+ if row is None:
30
+ return None
31
+ # basic check: if ended_at is set and in the past, treat as inactive
32
+ if row.ended_at is not None and row.ended_at <= now:
33
+ return None
34
+ return row
35
+
36
+
37
+ def require_quota(metric: str, *, window: str = "day", soft: bool = True):
38
+ async def _dep(tenant_id: TenantId, session: SqlSessionDep) -> None:
39
+ sub = await _current_subscription(session, tenant_id)
40
+ if sub is None:
41
+ # no subscription → allow (unlimited) by default
42
+ return
43
+ ent = (
44
+ (
45
+ await session.execute(
46
+ select(PlanEntitlement).where(
47
+ PlanEntitlement.plan_id == sub.plan_id,
48
+ PlanEntitlement.key == metric,
49
+ PlanEntitlement.window == window,
50
+ )
51
+ )
52
+ )
53
+ .scalars()
54
+ .first()
55
+ )
56
+ if ent is None:
57
+ # no entitlement → unlimited
58
+ return
59
+ # compute current window start
60
+ now = datetime.now(tz=timezone.utc)
61
+ if window == "day":
62
+ period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
63
+ granularity = "day"
64
+ elif window == "month":
65
+ period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
66
+ granularity = "month" # we only aggregate per day, but future-proof
67
+ else:
68
+ period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
69
+ granularity = "day"
70
+
71
+ used_row = (
72
+ (
73
+ await session.execute(
74
+ select(UsageAggregate).where(
75
+ UsageAggregate.tenant_id == tenant_id,
76
+ UsageAggregate.metric == metric,
77
+ UsageAggregate.granularity == granularity, # v1 daily baseline
78
+ UsageAggregate.period_start == period_start,
79
+ )
80
+ )
81
+ )
82
+ .scalars()
83
+ .first()
84
+ )
85
+ used = int(used_row.total) if used_row else 0
86
+ limit_ = int(ent.limit_per_window)
87
+ if used >= limit_:
88
+ if soft:
89
+ # allow but signal overage via header later (TODO: add header hook)
90
+ return
91
+ raise HTTPException(
92
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
93
+ detail=f"Quota exceeded for {metric} in {window} window",
94
+ )
95
+
96
+ return _dep
97
+
98
+
99
+ QuotaDep = Annotated[None, Depends(require_quota)]
100
+
101
+ __all__ = ["require_quota"]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, Field, conint
7
+
8
+
9
+ class UsageIn(BaseModel):
10
+ metric: str = Field(..., min_length=1, max_length=64)
11
+ amount: conint(ge=0) = Field(..., description="Non-negative amount for the metric")
12
+ at: Optional[datetime] = Field(
13
+ default=None, description="Event timestamp (UTC). Defaults to server time if omitted."
14
+ )
15
+ idempotency_key: str = Field(..., min_length=1, max_length=128)
16
+ metadata: dict = Field(default_factory=dict)
17
+
18
+
19
+ class UsageAckOut(BaseModel):
20
+ id: str
21
+ accepted: bool = True
22
+
23
+
24
+ class UsageAggregateRow(BaseModel):
25
+ period_start: datetime
26
+ granularity: str
27
+ metric: str
28
+ total: int
29
+
30
+
31
+ class UsageAggregatesOut(BaseModel):
32
+ items: list[UsageAggregateRow] = Field(default_factory=list)
33
+ next_cursor: Optional[str] = None
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Callable, Optional
7
+
8
+ from sqlalchemy import select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from .models import Invoice, InvoiceLine, UsageAggregate, UsageEvent
12
+
13
+ ProviderSyncHook = Callable[[Invoice, list[InvoiceLine]], None]
14
+
15
+
16
+ @dataclass
17
+ class BillingService:
18
+ session: Session
19
+ tenant_id: str
20
+ provider_sync: Optional[ProviderSyncHook] = None
21
+
22
+ def record_usage(
23
+ self, *, metric: str, amount: int, at: datetime, idempotency_key: str, metadata: dict | None
24
+ ) -> str:
25
+ # Ensure UTC
26
+ if at.tzinfo is None:
27
+ at = at.replace(tzinfo=timezone.utc)
28
+ evt = UsageEvent(
29
+ id=str(uuid.uuid4()),
30
+ tenant_id=self.tenant_id,
31
+ metric=metric,
32
+ amount=amount,
33
+ at_ts=at,
34
+ idempotency_key=idempotency_key,
35
+ metadata_json=metadata or {},
36
+ )
37
+ self.session.add(evt)
38
+ self.session.flush()
39
+ return evt.id
40
+
41
+ def aggregate_daily(self, *, metric: str, day_start: datetime) -> None:
42
+ # Compute [day_start, day_start+1d)
43
+ next_day = day_start.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
44
+ total = 0
45
+ rows = self.session.execute(
46
+ select(UsageEvent).where(
47
+ UsageEvent.tenant_id == self.tenant_id,
48
+ UsageEvent.metric == metric,
49
+ UsageEvent.at_ts >= day_start,
50
+ UsageEvent.at_ts < next_day,
51
+ )
52
+ ).scalars()
53
+ for r in rows:
54
+ total += int(r.amount)
55
+ # upsert aggregate
56
+ agg = self.session.execute(
57
+ select(UsageAggregate).where(
58
+ UsageAggregate.tenant_id == self.tenant_id,
59
+ UsageAggregate.metric == metric,
60
+ UsageAggregate.period_start == day_start,
61
+ UsageAggregate.granularity == "day",
62
+ )
63
+ ).scalar_one_or_none()
64
+ if agg:
65
+ agg.total = total
66
+ else:
67
+ self.session.add(
68
+ UsageAggregate(
69
+ id=str(uuid.uuid4()),
70
+ tenant_id=self.tenant_id,
71
+ metric=metric,
72
+ period_start=day_start,
73
+ granularity="day",
74
+ total=total,
75
+ )
76
+ )
77
+
78
+ def generate_monthly_invoice(
79
+ self, *, period_start: datetime, period_end: datetime, currency: str
80
+ ) -> str:
81
+ # Minimal: sum all daily aggregates and produce one line
82
+ total = 0
83
+ rows = self.session.execute(
84
+ select(UsageAggregate).where(
85
+ UsageAggregate.tenant_id == self.tenant_id,
86
+ UsageAggregate.period_start >= period_start,
87
+ UsageAggregate.period_start < period_end,
88
+ UsageAggregate.granularity == "day",
89
+ )
90
+ ).scalars()
91
+ for r in rows:
92
+ total += int(r.total)
93
+ inv = Invoice(
94
+ id=str(uuid.uuid4()),
95
+ tenant_id=self.tenant_id,
96
+ period_start=period_start,
97
+ period_end=period_end,
98
+ status="created",
99
+ total_amount=total,
100
+ currency=currency,
101
+ )
102
+ self.session.add(inv)
103
+ self.session.flush()
104
+ line = InvoiceLine(
105
+ id=str(uuid.uuid4()),
106
+ invoice_id=inv.id,
107
+ price_id=None,
108
+ metric=None,
109
+ quantity=1,
110
+ amount=total,
111
+ )
112
+ self.session.add(line)
113
+ if self.provider_sync:
114
+ self.provider_sync(inv, [line])
115
+ return inv.id
@@ -0,0 +1,5 @@
1
+ # Bundled Docs
2
+
3
+ This directory contains a minimal set of Markdown files that the `svc-infra docs` CLI can fall back to when the project running the CLI doesn't have a local `docs/` directory.
4
+
5
+ You can add more topics here as needed; each `*.md` file becomes a topic named after its stem (e.g., `getting-started.md` -> `getting-started`).
@@ -0,0 +1 @@
1
+ # Bundled docs package for zip-safe importlib.resources access
@@ -0,0 +1,6 @@
1
+ # Getting Started
2
+
3
+ Welcome to svc-infra docs. Use `svc-infra docs list` to see topics.
4
+
5
+ - This content is bundled with the package.
6
+ - If your project doesn't have a local `docs/` folder, you'll still see this.
@@ -5,6 +5,8 @@ This module offers high-level decorators for read/write caching, cache invalidat
5
5
  and resource-based cache management.
6
6
  """
7
7
 
8
+ from .add import add_cache
9
+
8
10
  # Core decorators - main public API
9
11
  from .decorators import cached # alias for cache_read
10
12
  from .decorators import mutates # alias for cache_write
@@ -32,4 +34,6 @@ __all__ = [
32
34
  # Resource-based caching
33
35
  "resource",
34
36
  "entity",
37
+ # Easy integration helper
38
+ "add_cache",
35
39
  ]
svc_infra/cache/add.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Easy integration helper to wire the cache backend into an ASGI app lifecycle.
5
+
6
+ Contract:
7
+ - Idempotent: multiple calls are safe; startup/shutdown handlers are registered once.
8
+ - Env-driven defaults: respects CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION, APP_ENV.
9
+ - Lifecycle: registers startup (init + readiness probe) and shutdown (graceful close).
10
+ - Ergonomics: exposes the underlying cache instance at app.state.cache by default.
11
+
12
+ This does not replace the per-function decorators (`cache_read`, `cache_write`) and
13
+ does not alter existing direct APIs; it simply standardizes initialization and wiring.
14
+ """
15
+
16
+ import logging
17
+ import os
18
+ from typing import Any, Callable, Optional
19
+
20
+ from svc_infra.cache.backend import DEFAULT_READINESS_TIMEOUT
21
+ from svc_infra.cache.backend import instance as _instance
22
+ from svc_infra.cache.backend import setup_cache as _setup_cache
23
+ from svc_infra.cache.backend import shutdown_cache as _shutdown_cache
24
+ from svc_infra.cache.backend import wait_ready as _wait_ready
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _derive_settings(
30
+ url: Optional[str], prefix: Optional[str], version: Optional[str]
31
+ ) -> tuple[str, str, str]:
32
+ """Derive cache settings from parameters or environment variables.
33
+
34
+ Precedence:
35
+ - explicit function arguments
36
+ - environment variables (CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION)
37
+ - sensible defaults (mem://, "svc", "v1")
38
+ """
39
+
40
+ derived_url = url or os.getenv("CACHE_URL") or os.getenv("REDIS_URL") or "mem://"
41
+ derived_prefix = prefix or os.getenv("CACHE_PREFIX") or "svc"
42
+ derived_version = version or os.getenv("CACHE_VERSION") or "v1"
43
+ return derived_url, derived_prefix, derived_version
44
+
45
+
46
+ def add_cache(
47
+ app: Any | None = None,
48
+ *,
49
+ url: str | None = None,
50
+ prefix: str | None = None,
51
+ version: str | None = None,
52
+ readiness_timeout: float | None = None,
53
+ expose_state: bool = True,
54
+ state_key: str = "cache",
55
+ ) -> Callable[[], None]:
56
+ """Wire cache initialization and lifecycle into the ASGI app.
57
+
58
+ If an app is provided, registers startup/shutdown handlers. Otherwise performs
59
+ immediate initialization (best-effort) without awaiting readiness.
60
+
61
+ Returns a no-op shutdown callable for API symmetry with other helpers.
62
+ """
63
+
64
+ # Compute effective settings
65
+ eff_url, eff_prefix, eff_version = _derive_settings(url, prefix, version)
66
+
67
+ # If no app provided, do a simple init and return
68
+ if app is None:
69
+ try:
70
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
71
+ logger.info(
72
+ "Cache initialized (no app wiring): backend=%s namespace=%s",
73
+ eff_url,
74
+ f"{eff_prefix}:{eff_version}",
75
+ )
76
+ except Exception:
77
+ logger.exception("Cache initialization failed (no app wiring)")
78
+ return lambda: None
79
+
80
+ # Idempotence: avoid duplicate wiring
81
+ try:
82
+ state = getattr(app, "state", None)
83
+ already = bool(getattr(state, "_svc_cache_wired", False))
84
+ except Exception:
85
+ state = None
86
+ already = False
87
+
88
+ if already:
89
+ logger.debug("add_cache: app already wired; skipping re-registration")
90
+ return lambda: None
91
+
92
+ # Define lifecycle handlers
93
+ async def _startup():
94
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
95
+ try:
96
+ await _wait_ready(timeout=readiness_timeout or DEFAULT_READINESS_TIMEOUT)
97
+ except Exception:
98
+ # Bubble up to fail fast on startup; tests and prod prefer visibility
99
+ logger.exception("Cache readiness probe failed during startup")
100
+ raise
101
+ # Expose cache instance for convenience
102
+ if expose_state and hasattr(app, "state"):
103
+ try:
104
+ setattr(app.state, state_key, _instance())
105
+ except Exception:
106
+ logger.debug("Unable to expose cache instance on app.state", exc_info=True)
107
+
108
+ async def _shutdown():
109
+ try:
110
+ await _shutdown_cache()
111
+ except Exception:
112
+ # Best-effort; shutdown should not crash the app
113
+ logger.debug("Cache shutdown encountered errors (ignored)", exc_info=True)
114
+
115
+ # Register event handlers when supported
116
+ register_ok = False
117
+ try:
118
+ if hasattr(app, "add_event_handler"):
119
+ app.add_event_handler("startup", _startup)
120
+ app.add_event_handler("shutdown", _shutdown)
121
+ register_ok = True
122
+ except Exception:
123
+ register_ok = False
124
+
125
+ if not register_ok:
126
+ # Fallback: attempt FastAPI/Starlette .on_event decorators dynamically
127
+ try:
128
+ on_event = getattr(app, "on_event", None)
129
+ if callable(on_event):
130
+ on_event("startup")(_startup) # type: ignore[misc]
131
+ on_event("shutdown")(_shutdown) # type: ignore[misc]
132
+ register_ok = True
133
+ except Exception:
134
+ register_ok = False
135
+
136
+ # Mark wired and expose state immediately if desired
137
+ if hasattr(app, "state"):
138
+ try:
139
+ setattr(app.state, "_svc_cache_wired", True)
140
+ if expose_state and not hasattr(app.state, state_key):
141
+ setattr(app.state, state_key, _instance())
142
+ except Exception:
143
+ pass
144
+
145
+ if register_ok:
146
+ logger.info("Cache wired: url=%s namespace=%s", eff_url, f"{eff_prefix}:{eff_version}")
147
+ else:
148
+ # If we cannot register handlers, at least initialize now
149
+ try:
150
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
151
+ except Exception:
152
+ logger.exception("Cache initialization failed (no event registration)")
153
+
154
+ # Return a simple shutdown handle for symmetry with other add_* helpers
155
+ return lambda: None
156
+
157
+
158
+ __all__ = ["add_cache"]
@@ -80,9 +80,12 @@ def setup_cache(
80
80
  logger.info(f"Cache version updated to: {_current_version}")
81
81
 
82
82
  # Setup backend connection
83
+ # Newer cashews versions require an explicit settings_url; default to in-memory
84
+ # backend when no URL is provided so acceptance/unit tests work out of the box.
83
85
  try:
84
- setup_awaitable = _cache.setup(url) if url else _cache.setup()
85
- logger.info(f"Cache backend setup initiated with URL: {url or 'default'}")
86
+ settings_url = url or "mem://"
87
+ setup_awaitable = _cache.setup(settings_url)
88
+ logger.info(f"Cache backend setup initiated with URL: {settings_url}")
86
89
  except Exception as e:
87
90
  logger.error(f"Failed to setup cache backend: {e}")
88
91
  raise
@@ -98,7 +98,25 @@ def cache_read(
98
98
  ttl_val = validate_ttl(ttl)
99
99
  template = build_key_template(key)
100
100
  namespace = _alias() or ""
101
- tags_func = create_tags_function(tags)
101
+ # Build a tags function that renders any templates against the call kwargs
102
+ base_tags_func = create_tags_function(tags)
103
+
104
+ def tags_func(*_args, **call_kwargs):
105
+ try:
106
+ raw = base_tags_func(*_args, **call_kwargs) or []
107
+ rendered = []
108
+ for t in raw:
109
+ if isinstance(t, str) and ("{" in t and "}" in t):
110
+ try:
111
+ rendered.append(t.format(**call_kwargs))
112
+ except Exception:
113
+ # Best effort: fall back to original
114
+ rendered.append(t)
115
+ else:
116
+ rendered.append(t)
117
+ return rendered
118
+ except Exception:
119
+ return raw if isinstance(raw, list) else []
102
120
 
103
121
  def _decorator(func: Callable[..., Awaitable[Any]]):
104
122
  # Try different cashews cache decorator signatures for compatibility
svc_infra/cache/keys.py CHANGED
@@ -88,12 +88,32 @@ def build_key_variants_renderer(template: str) -> Callable[..., list[str]]:
88
88
 
89
89
 
90
90
  def resolve_tags(tags, *args, **kwargs) -> list[str]:
91
- """Resolve tags from static list or callable."""
91
+ """Resolve tags from static list or callable and render templates with kwargs.
92
+
93
+ Supports entries like "thing:{id}" which will be formatted using provided kwargs.
94
+ Non-string items are passed through as str(). Missing keys are skipped with a warning.
95
+ """
92
96
  try:
97
+ # 1) Obtain raw tags list
93
98
  if callable(tags):
94
- result = tags(*args, **kwargs)
95
- return list(result) if result is not None else []
96
- return list(tags)
99
+ raw = tags(*args, **kwargs)
100
+ raw_list = list(raw) if raw is not None else []
101
+ else:
102
+ raw_list = list(tags)
103
+
104
+ # 2) Render any templates using kwargs
105
+ rendered: list[str] = []
106
+ for t in raw_list:
107
+ try:
108
+ if isinstance(t, str) and ("{" in t and "}" in t):
109
+ rendered.append(t.format(**kwargs))
110
+ else:
111
+ rendered.append(str(t))
112
+ except KeyError as e:
113
+ logger.warning(f"Tag template missing key {e} in '{t}'")
114
+ except Exception as e:
115
+ logger.warning(f"Failed to render tag '{t}': {e}")
116
+ return [r for r in rendered if r]
97
117
  except Exception as e:
98
118
  logger.error(f"Failed to resolve cache tags: {e}")
99
119
  return []