svc-infra 0.1.589__py3-none-any.whl → 0.1.706__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,177 @@
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(
19
+ String(TENANT_ID_LEN), index=True, nullable=False
20
+ )
21
+ metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
22
+ amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
23
+ at_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
24
+ idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
25
+ metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
26
+ created_at: Mapped[datetime] = mapped_column(
27
+ DateTime(timezone=True),
28
+ server_default=text("CURRENT_TIMESTAMP"),
29
+ nullable=False,
30
+ )
31
+
32
+ __table_args__ = (
33
+ UniqueConstraint(
34
+ "tenant_id", "metric", "idempotency_key", name="uq_usage_idem"
35
+ ),
36
+ Index("ix_usage_tenant_metric_ts", "tenant_id", "metric", "at_ts"),
37
+ )
38
+
39
+
40
+ class UsageAggregate(ModelBase):
41
+ __tablename__ = "billing_usage_aggregates"
42
+
43
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
44
+ tenant_id: Mapped[str] = mapped_column(
45
+ String(TENANT_ID_LEN), index=True, nullable=False
46
+ )
47
+ metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
48
+ period_start: Mapped[datetime] = mapped_column(
49
+ DateTime(timezone=True), nullable=False
50
+ )
51
+ granularity: Mapped[str] = mapped_column(
52
+ String(8), nullable=False
53
+ ) # hour|day|month
54
+ total: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
55
+ updated_at: Mapped[datetime] = mapped_column(
56
+ DateTime(timezone=True),
57
+ server_default=text("CURRENT_TIMESTAMP"),
58
+ nullable=False,
59
+ )
60
+
61
+ __table_args__ = (
62
+ UniqueConstraint(
63
+ "tenant_id", "metric", "period_start", "granularity", name="uq_usage_agg"
64
+ ),
65
+ )
66
+
67
+
68
+ class Plan(ModelBase):
69
+ __tablename__ = "billing_plans"
70
+
71
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
72
+ key: Mapped[str] = mapped_column(
73
+ String(64), unique=True, index=True, nullable=False
74
+ )
75
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
76
+ description: Mapped[Optional[str]] = mapped_column(String(255))
77
+ created_at: Mapped[datetime] = mapped_column(
78
+ DateTime(timezone=True),
79
+ server_default=text("CURRENT_TIMESTAMP"),
80
+ nullable=False,
81
+ )
82
+
83
+
84
+ class PlanEntitlement(ModelBase):
85
+ __tablename__ = "billing_plan_entitlements"
86
+
87
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
88
+ plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
89
+ key: Mapped[str] = mapped_column(String(64), nullable=False)
90
+ limit_per_window: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
91
+ window: Mapped[str] = mapped_column(String(8), nullable=False) # day|month
92
+ created_at: Mapped[datetime] = mapped_column(
93
+ DateTime(timezone=True),
94
+ server_default=text("CURRENT_TIMESTAMP"),
95
+ nullable=False,
96
+ )
97
+
98
+
99
+ class Subscription(ModelBase):
100
+ __tablename__ = "billing_subscriptions"
101
+
102
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
103
+ tenant_id: Mapped[str] = mapped_column(
104
+ String(TENANT_ID_LEN), index=True, nullable=False
105
+ )
106
+ plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
107
+ effective_at: Mapped[datetime] = mapped_column(
108
+ DateTime(timezone=True), nullable=False
109
+ )
110
+ ended_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
111
+ created_at: Mapped[datetime] = mapped_column(
112
+ DateTime(timezone=True),
113
+ server_default=text("CURRENT_TIMESTAMP"),
114
+ nullable=False,
115
+ )
116
+
117
+
118
+ class Price(ModelBase):
119
+ __tablename__ = "billing_prices"
120
+
121
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
122
+ key: Mapped[str] = mapped_column(
123
+ String(64), unique=True, index=True, nullable=False
124
+ )
125
+ currency: Mapped[str] = mapped_column(String(8), nullable=False)
126
+ unit_amount: Mapped[int] = mapped_column(
127
+ Numeric(18, 0), nullable=False
128
+ ) # minor units
129
+ metric: Mapped[Optional[str]] = mapped_column(
130
+ String(64)
131
+ ) # null for fixed recurring
132
+ recurring_interval: Mapped[Optional[str]] = mapped_column(String(8)) # month|year
133
+ created_at: Mapped[datetime] = mapped_column(
134
+ DateTime(timezone=True),
135
+ server_default=text("CURRENT_TIMESTAMP"),
136
+ nullable=False,
137
+ )
138
+
139
+
140
+ class Invoice(ModelBase):
141
+ __tablename__ = "billing_invoices"
142
+
143
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
144
+ tenant_id: Mapped[str] = mapped_column(
145
+ String(TENANT_ID_LEN), index=True, nullable=False
146
+ )
147
+ period_start: Mapped[datetime] = mapped_column(
148
+ DateTime(timezone=True), nullable=False
149
+ )
150
+ period_end: Mapped[datetime] = mapped_column(
151
+ DateTime(timezone=True), nullable=False
152
+ )
153
+ status: Mapped[str] = mapped_column(String(16), index=True, nullable=False)
154
+ total_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
155
+ currency: Mapped[str] = mapped_column(String(8), nullable=False)
156
+ provider_invoice_id: Mapped[Optional[str]] = mapped_column(String(128), index=True)
157
+ created_at: Mapped[datetime] = mapped_column(
158
+ DateTime(timezone=True),
159
+ server_default=text("CURRENT_TIMESTAMP"),
160
+ nullable=False,
161
+ )
162
+
163
+
164
+ class InvoiceLine(ModelBase):
165
+ __tablename__ = "billing_invoice_lines"
166
+
167
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
168
+ invoice_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
169
+ price_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
170
+ metric: Mapped[Optional[str]] = mapped_column(String(64))
171
+ quantity: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
172
+ amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
173
+ created_at: Mapped[datetime] = mapped_column(
174
+ DateTime(timezone=True),
175
+ server_default=text("CURRENT_TIMESTAMP"),
176
+ nullable=False,
177
+ )
@@ -0,0 +1,103 @@
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(
17
+ session: AsyncSession, tenant_id: str
18
+ ) -> Optional[Subscription]:
19
+ now = datetime.now(tz=timezone.utc)
20
+ row = (
21
+ (
22
+ await session.execute(
23
+ select(Subscription)
24
+ .where(Subscription.tenant_id == tenant_id)
25
+ .order_by(Subscription.effective_at.desc())
26
+ )
27
+ )
28
+ .scalars()
29
+ .first()
30
+ )
31
+ if row is None:
32
+ return None
33
+ # basic check: if ended_at is set and in the past, treat as inactive
34
+ if row.ended_at is not None and row.ended_at <= now:
35
+ return None
36
+ return row
37
+
38
+
39
+ def require_quota(metric: str, *, window: str = "day", soft: bool = True):
40
+ async def _dep(tenant_id: TenantId, session: SqlSessionDep) -> None:
41
+ sub = await _current_subscription(session, tenant_id)
42
+ if sub is None:
43
+ # no subscription → allow (unlimited) by default
44
+ return
45
+ ent = (
46
+ (
47
+ await session.execute(
48
+ select(PlanEntitlement).where(
49
+ PlanEntitlement.plan_id == sub.plan_id,
50
+ PlanEntitlement.key == metric,
51
+ PlanEntitlement.window == window,
52
+ )
53
+ )
54
+ )
55
+ .scalars()
56
+ .first()
57
+ )
58
+ if ent is None:
59
+ # no entitlement → unlimited
60
+ return
61
+ # compute current window start
62
+ now = datetime.now(tz=timezone.utc)
63
+ if window == "day":
64
+ period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
65
+ granularity = "day"
66
+ elif window == "month":
67
+ period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
68
+ granularity = "month" # we only aggregate per day, but future-proof
69
+ else:
70
+ period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
71
+ granularity = "day"
72
+
73
+ used_row = (
74
+ (
75
+ await session.execute(
76
+ select(UsageAggregate).where(
77
+ UsageAggregate.tenant_id == tenant_id,
78
+ UsageAggregate.metric == metric,
79
+ UsageAggregate.granularity == granularity, # v1 daily baseline
80
+ UsageAggregate.period_start == period_start,
81
+ )
82
+ )
83
+ )
84
+ .scalars()
85
+ .first()
86
+ )
87
+ used = int(used_row.total) if used_row else 0
88
+ limit_ = int(ent.limit_per_window)
89
+ if used >= limit_:
90
+ if soft:
91
+ # allow but signal overage via header later (TODO: add header hook)
92
+ return
93
+ raise HTTPException(
94
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
95
+ detail=f"Quota exceeded for {metric} in {window} window",
96
+ )
97
+
98
+ return _dep
99
+
100
+
101
+ QuotaDep = Annotated[None, Depends(require_quota)]
102
+
103
+ __all__ = ["require_quota"]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Annotated, Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class UsageIn(BaseModel):
10
+ metric: str = Field(..., min_length=1, max_length=64)
11
+ amount: Annotated[
12
+ int, Field(ge=0, description="Non-negative amount for the metric")
13
+ ]
14
+ at: Optional[datetime] = Field(
15
+ default=None,
16
+ description="Event timestamp (UTC). Defaults to server time if omitted.",
17
+ )
18
+ idempotency_key: str = Field(..., min_length=1, max_length=128)
19
+ metadata: dict = Field(default_factory=dict)
20
+
21
+
22
+ class UsageAckOut(BaseModel):
23
+ id: str
24
+ accepted: bool = True
25
+
26
+
27
+ class UsageAggregateRow(BaseModel):
28
+ period_start: datetime
29
+ granularity: str
30
+ metric: str
31
+ total: int
32
+
33
+
34
+ class UsageAggregatesOut(BaseModel):
35
+ items: list[UsageAggregateRow] = Field(default_factory=list)
36
+ next_cursor: Optional[str] = None
@@ -0,0 +1,123 @@
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,
24
+ *,
25
+ metric: str,
26
+ amount: int,
27
+ at: datetime,
28
+ idempotency_key: str,
29
+ metadata: dict | None,
30
+ ) -> str:
31
+ # Ensure UTC
32
+ if at.tzinfo is None:
33
+ at = at.replace(tzinfo=timezone.utc)
34
+ evt = UsageEvent(
35
+ id=str(uuid.uuid4()),
36
+ tenant_id=self.tenant_id,
37
+ metric=metric,
38
+ amount=amount,
39
+ at_ts=at,
40
+ idempotency_key=idempotency_key,
41
+ metadata_json=metadata or {},
42
+ )
43
+ self.session.add(evt)
44
+ self.session.flush()
45
+ return evt.id
46
+
47
+ def aggregate_daily(self, *, metric: str, day_start: datetime) -> None:
48
+ # Compute [day_start, day_start+1d)
49
+ next_day = day_start.replace(
50
+ hour=0, minute=0, second=0, microsecond=0
51
+ ) + timedelta(days=1)
52
+ total = 0
53
+ rows = self.session.execute(
54
+ select(UsageEvent).where(
55
+ UsageEvent.tenant_id == self.tenant_id,
56
+ UsageEvent.metric == metric,
57
+ UsageEvent.at_ts >= day_start,
58
+ UsageEvent.at_ts < next_day,
59
+ )
60
+ ).scalars()
61
+ for r in rows:
62
+ total += int(r.amount)
63
+ # upsert aggregate
64
+ agg = self.session.execute(
65
+ select(UsageAggregate).where(
66
+ UsageAggregate.tenant_id == self.tenant_id,
67
+ UsageAggregate.metric == metric,
68
+ UsageAggregate.period_start == day_start,
69
+ UsageAggregate.granularity == "day",
70
+ )
71
+ ).scalar_one_or_none()
72
+ if agg:
73
+ agg.total = total
74
+ else:
75
+ self.session.add(
76
+ UsageAggregate(
77
+ id=str(uuid.uuid4()),
78
+ tenant_id=self.tenant_id,
79
+ metric=metric,
80
+ period_start=day_start,
81
+ granularity="day",
82
+ total=total,
83
+ )
84
+ )
85
+
86
+ def generate_monthly_invoice(
87
+ self, *, period_start: datetime, period_end: datetime, currency: str
88
+ ) -> str:
89
+ # Minimal: sum all daily aggregates and produce one line
90
+ total = 0
91
+ rows = self.session.execute(
92
+ select(UsageAggregate).where(
93
+ UsageAggregate.tenant_id == self.tenant_id,
94
+ UsageAggregate.period_start >= period_start,
95
+ UsageAggregate.period_start < period_end,
96
+ UsageAggregate.granularity == "day",
97
+ )
98
+ ).scalars()
99
+ for r in rows:
100
+ total += int(r.total)
101
+ inv = Invoice(
102
+ id=str(uuid.uuid4()),
103
+ tenant_id=self.tenant_id,
104
+ period_start=period_start,
105
+ period_end=period_end,
106
+ status="created",
107
+ total_amount=total,
108
+ currency=currency,
109
+ )
110
+ self.session.add(inv)
111
+ self.session.flush()
112
+ line = InvoiceLine(
113
+ id=str(uuid.uuid4()),
114
+ invoice_id=inv.id,
115
+ price_id=None,
116
+ metric=None,
117
+ quantity=1,
118
+ amount=total,
119
+ )
120
+ self.session.add(line)
121
+ if self.provider_sync:
122
+ self.provider_sync(inv, [line])
123
+ 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,11 @@ 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
+
10
+ # Cache instance access for object-oriented usage
11
+ from .backend import get_cache
12
+
8
13
  # Core decorators - main public API
9
14
  from .decorators import cached # alias for cache_read
10
15
  from .decorators import mutates # alias for cache_write
@@ -32,4 +37,8 @@ __all__ = [
32
37
  # Resource-based caching
33
38
  "resource",
34
39
  "entity",
40
+ # Easy integration helper
41
+ "add_cache",
42
+ # Cache instance access
43
+ "get_cache",
35
44
  ]
svc_infra/cache/add.py ADDED
@@ -0,0 +1,170 @@
1
+ """Easy integration helper to wire the cache backend into an ASGI app lifecycle.
2
+
3
+ Contract:
4
+ - Idempotent: multiple calls are safe; startup/shutdown handlers are registered once.
5
+ - Env-driven defaults: respects CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION, APP_ENV.
6
+ - Lifecycle: registers startup (init + readiness probe) and shutdown (graceful close).
7
+ - Ergonomics: exposes the underlying cache instance at app.state.cache by default.
8
+
9
+ This does not replace the per-function decorators (`cache_read`, `cache_write`) and
10
+ does not alter existing direct APIs; it simply standardizes initialization and wiring.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ from typing import Any, Callable, Optional
18
+
19
+ from svc_infra.cache.backend import DEFAULT_READINESS_TIMEOUT
20
+ from svc_infra.cache.backend import get_cache as _get_cache
21
+ from svc_infra.cache.backend import setup_cache as _setup_cache
22
+ from svc_infra.cache.backend import shutdown_cache as _shutdown_cache
23
+ from svc_infra.cache.backend import wait_ready as _wait_ready
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _instance() -> Any:
29
+ """Return the current cache instance.
30
+
31
+ This is a thin compatibility shim used by tests and older callers.
32
+ """
33
+
34
+ return _get_cache()
35
+
36
+
37
+ def _derive_settings(
38
+ url: Optional[str], prefix: Optional[str], version: Optional[str]
39
+ ) -> tuple[str, str, str]:
40
+ """Derive cache settings from parameters or environment variables.
41
+
42
+ Precedence:
43
+ - explicit function arguments
44
+ - environment variables (CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION)
45
+ - sensible defaults (mem://, "svc", "v1")
46
+ """
47
+
48
+ derived_url = url or os.getenv("CACHE_URL") or os.getenv("REDIS_URL") or "mem://"
49
+ derived_prefix = prefix or os.getenv("CACHE_PREFIX") or "svc"
50
+ derived_version = version or os.getenv("CACHE_VERSION") or "v1"
51
+ return derived_url, derived_prefix, derived_version
52
+
53
+
54
+ def add_cache(
55
+ app: Any | None = None,
56
+ *,
57
+ url: str | None = None,
58
+ prefix: str | None = None,
59
+ version: str | None = None,
60
+ readiness_timeout: float | None = None,
61
+ expose_state: bool = True,
62
+ state_key: str = "cache",
63
+ ) -> Callable[[], None]:
64
+ """Wire cache initialization and lifecycle into the ASGI app.
65
+
66
+ If an app is provided, registers startup/shutdown handlers. Otherwise performs
67
+ immediate initialization (best-effort) without awaiting readiness.
68
+
69
+ Returns a no-op shutdown callable for API symmetry with other helpers.
70
+ """
71
+
72
+ # Compute effective settings
73
+ eff_url, eff_prefix, eff_version = _derive_settings(url, prefix, version)
74
+
75
+ # If no app provided, do a simple init and return
76
+ if app is None:
77
+ try:
78
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
79
+ logger.info(
80
+ "Cache initialized (no app wiring): backend=%s namespace=%s",
81
+ eff_url,
82
+ f"{eff_prefix}:{eff_version}",
83
+ )
84
+ except Exception:
85
+ logger.exception("Cache initialization failed (no app wiring)")
86
+ return lambda: None
87
+
88
+ # Idempotence: avoid duplicate wiring
89
+ try:
90
+ state = getattr(app, "state", None)
91
+ already = bool(getattr(state, "_svc_cache_wired", False))
92
+ except Exception:
93
+ state = None
94
+ already = False
95
+
96
+ if already:
97
+ logger.debug("add_cache: app already wired; skipping re-registration")
98
+ return lambda: None
99
+
100
+ # Define lifecycle handlers
101
+ async def _startup():
102
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
103
+ try:
104
+ await _wait_ready(timeout=readiness_timeout or DEFAULT_READINESS_TIMEOUT)
105
+ except Exception:
106
+ # Bubble up to fail fast on startup; tests and prod prefer visibility
107
+ logger.exception("Cache readiness probe failed during startup")
108
+ raise
109
+ # Expose cache instance for convenience
110
+ if expose_state and hasattr(app, "state"):
111
+ try:
112
+ setattr(app.state, state_key, _instance())
113
+ except Exception:
114
+ logger.debug(
115
+ "Unable to expose cache instance on app.state", exc_info=True
116
+ )
117
+
118
+ async def _shutdown():
119
+ try:
120
+ await _shutdown_cache()
121
+ except Exception:
122
+ # Best-effort; shutdown should not crash the app
123
+ logger.debug("Cache shutdown encountered errors (ignored)", exc_info=True)
124
+
125
+ # Register event handlers when supported
126
+ register_ok = False
127
+ try:
128
+ if hasattr(app, "add_event_handler"):
129
+ app.add_event_handler("startup", _startup)
130
+ app.add_event_handler("shutdown", _shutdown)
131
+ register_ok = True
132
+ except Exception:
133
+ register_ok = False
134
+
135
+ if not register_ok:
136
+ # Fallback: attempt FastAPI/Starlette .on_event decorators dynamically
137
+ try:
138
+ on_event = getattr(app, "on_event", None)
139
+ if callable(on_event):
140
+ on_event("startup")(_startup)
141
+ on_event("shutdown")(_shutdown)
142
+ register_ok = True
143
+ except Exception:
144
+ register_ok = False
145
+
146
+ # Mark wired and expose state immediately if desired
147
+ if hasattr(app, "state"):
148
+ try:
149
+ setattr(app.state, "_svc_cache_wired", True)
150
+ if expose_state and not hasattr(app.state, state_key):
151
+ setattr(app.state, state_key, _instance())
152
+ except Exception:
153
+ pass
154
+
155
+ if register_ok:
156
+ logger.info(
157
+ "Cache wired: url=%s namespace=%s", eff_url, f"{eff_prefix}:{eff_version}"
158
+ )
159
+ else:
160
+ # If we cannot register handlers, at least initialize now
161
+ try:
162
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
163
+ except Exception:
164
+ logger.exception("Cache initialization failed (no event registration)")
165
+
166
+ # Return a simple shutdown handle for symmetry with other add_* helpers
167
+ return lambda: None
168
+
169
+
170
+ __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
@@ -115,9 +118,7 @@ async def wait_ready(timeout: float = DEFAULT_READINESS_TIMEOUT) -> None:
115
118
  retrieved_value = await _cache.get(probe_key)
116
119
 
117
120
  if retrieved_value != PROBE_VALUE:
118
- error_msg = (
119
- f"Cache readiness probe failed. Expected '{PROBE_VALUE}', got '{retrieved_value}'"
120
- )
121
+ error_msg = f"Cache readiness probe failed. Expected '{PROBE_VALUE}', got '{retrieved_value}'"
121
122
  logger.error(error_msg)
122
123
  raise RuntimeError(error_msg)
123
124
 
@@ -144,7 +145,7 @@ async def shutdown_cache() -> None:
144
145
  logger.warning(f"Error during cache shutdown (ignored): {e}")
145
146
 
146
147
 
147
- def instance():
148
+ def get_cache():
148
149
  """
149
150
  Get the underlying cashews cache instance.
150
151