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
@@ -2,15 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import logging
5
- from typing import List
6
- import sys, pathlib, importlib.util
5
+ from typing import List, Tuple
6
+ import sys, pathlib, importlib, pkgutil, traceback
7
7
 
8
8
  from alembic import context
9
9
  from sqlalchemy.engine import make_url, URL
10
10
 
11
11
  from svc_infra.db.sql.utils import (
12
- _coerce_sync_driver,
13
- _ensure_ssl_default,
14
12
  get_database_url_from_env,
15
13
  build_engine,
16
14
  )
@@ -20,6 +18,7 @@ try:
20
18
  except Exception:
21
19
  _GUID = None
22
20
 
21
+
23
22
  def _render_item(type_, obj, autogen_context):
24
23
  if type_ == "type" and (
25
24
  (_GUID is not None and isinstance(obj, _GUID))
@@ -29,38 +28,49 @@ def _render_item(type_, obj, autogen_context):
29
28
  return "GUID()"
30
29
  return False
31
30
 
32
- # Logging
31
+
32
+ # ---- Logging ----
33
33
  config = context.config
34
34
  if config.config_file_name is not None:
35
- import logging.config
36
- logging.config.fileConfig(config.config_file_name)
35
+ import logging.config as _lc
36
+ _lc.fileConfig(config.config_file_name)
37
+
37
38
  logger = logging.getLogger(__name__)
39
+ logger.setLevel(logging.INFO)
40
+
38
41
 
39
- # --- sys.path bootstrap for src-layout projects ---
42
+ # ---- sys.path bootstrap (append; do NOT shadow site-packages) ----
40
43
  prepend = config.get_main_option("prepend_sys_path") or ""
44
+ script_loc = config.get_main_option("script_location") or os.path.dirname(__file__)
45
+ migrations_dir = pathlib.Path(script_loc).resolve()
46
+ project_root = migrations_dir.parent
47
+
48
+ def _ensure_on_syspath_end(p: pathlib.Path) -> None:
49
+ s = str(p)
50
+ if s and s not in sys.path:
51
+ sys.path.append(s) # append instead of insert(0) to avoid shadowing installed packages
52
+
41
53
  if prepend:
42
- if prepend not in sys.path:
43
- sys.path.insert(0, prepend)
54
+ _ensure_on_syspath_end(pathlib.Path(prepend))
44
55
  src_path = pathlib.Path(prepend) / "src"
45
56
  if src_path.exists():
46
- s = str(src_path)
47
- if s not in sys.path:
48
- sys.path.insert(0, s)
57
+ _ensure_on_syspath_end(src_path)
58
+
59
+ _ensure_on_syspath_end(project_root)
60
+ if (project_root / "src").exists():
61
+ _ensure_on_syspath_end(project_root / "src")
62
+
49
63
 
50
- # --- robust x-arg parsing for all Alembic versions ---
64
+ # ---- x-args ----
51
65
  def _x_args_dict() -> dict:
52
66
  try:
53
- # Alembic >= 1.8 uses as_dictionary kw only
54
67
  return context.get_x_argument(as_dictionary=True) # type: ignore[arg-type]
55
68
  except TypeError:
56
69
  try:
57
- # Some versions just return a list even if you pass kw
58
70
  xs = context.get_x_argument()
59
71
  except TypeError:
60
- # Very old signatures
61
72
  xs = []
62
- # Parse ["k=v", "flag"] into dict
63
- out = {}
73
+ out: dict = {}
64
74
  for item in xs:
65
75
  if "=" in item:
66
76
  k, v = item.split("=", 1)
@@ -69,7 +79,8 @@ def _x_args_dict() -> dict:
69
79
  out[item] = ""
70
80
  return out
71
81
 
72
- # --- Resolve effective DB URL (priority: -x -> env -> helper -> ini) ---
82
+
83
+ # ---- DB URL resolution ----
73
84
  _x = _x_args_dict()
74
85
  cli_dburl = _x.get("dburl", "").strip()
75
86
  env_dburl = os.getenv("SQL_URL", "").strip()
@@ -90,103 +101,215 @@ if not effective_url:
90
101
 
91
102
  u = make_url(effective_url)
92
103
  u = _coerce_sync_driver(u)
93
- u = _ensure_ssl_default(u)
94
104
  config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
95
105
 
96
- # --- metadata discovery (prefer ModelBase) ---
97
- DISCOVER_PACKAGES: List[str] = [__PACKAGES_LIST__]
106
+
107
+ # ---- feature flags / constants ----
108
+ WANT_PAYMENTS = os.getenv("APF_ENABLE_PAYMENTS", "").lower() in {"1", "true", "yes"}
109
+ FORCE_PAYMENTS = os.getenv("ALEMBIC_FORCE_PAYMENTS", "").lower() in {"1", "true", "yes"}
110
+ PAYMENT_TABLES = {"pay_customers", "pay_intents", "pay_events", "ledger_entries"}
111
+
112
+
113
+ # ---- metadata discovery (prefer ModelBase; scan ALL attrs) ----
114
+ # IMPORTANT: do NOT seed with payments package unless enabled/forced.
115
+ DISCOVER_PACKAGES: List[str] = [] # seed empty; merged with FS + env
98
116
  ENV_DISCOVER = os.getenv("ALEMBIC_DISCOVER_PACKAGES")
99
117
  if ENV_DISCOVER:
100
- DISCOVER_PACKAGES = [s.strip() for s in ENV_DISCOVER.split(',') if s.strip()]
118
+ DISCOVER_PACKAGES = [s.strip() for s in ENV_DISCOVER.split(",") if s.strip()]
101
119
 
102
120
  def _collect_metadata() -> list[object]:
103
- try:
104
- from svc_infra.db.sql.base import ModelBase # type: ignore
105
- md = getattr(ModelBase, "metadata", None)
106
- if md is not None and hasattr(md, "tables") and md.tables:
107
- return [md]
108
- except Exception as e:
109
- logger.debug("ModelBase not available or empty: %s", e)
110
-
111
- import importlib, pkgutil, pathlib
121
+ """
122
+ Strategy:
123
+ 1) (If WANT_PAYMENTS or FORCE_PAYMENTS) force-import payments module and log result.
124
+ 2) Import packages + ALEMBIC_DISCOVER_PACKAGES (+ payments only when enabled).
125
+ 3) ALSO import top-level packages under project root and src/.
126
+ 4) Import common model-bearing submodules.
127
+ 5) Prefer ModelBase.metadata after imports.
128
+ 6) Collect ANY object whose `.metadata` has tables (scan ALL attrs).
129
+ 7) De-dup and keep only those with at least one table.
130
+ """
131
+ tried: list[Tuple[str, str]] = []
132
+ errors: list[Tuple[str, str]] = []
112
133
  found: list[object] = []
113
134
 
135
+ def _note(name: str, ok: bool, err: str | None = None):
136
+ tried.append((name, "ok" if ok else "err"))
137
+ if not ok and err:
138
+ errors.append((name, err))
139
+
114
140
  def _maybe_add(obj: object) -> None:
115
141
  md = getattr(obj, "metadata", None) or obj
116
- if hasattr(md, "tables") and hasattr(md, "schema"):
142
+ if hasattr(md, "tables") and getattr(md, "tables"):
117
143
  found.append(md)
118
144
 
119
- pkgs = list(DISCOVER_PACKAGES) or []
120
- if not pkgs:
121
- roots = []
122
- if prepend:
123
- roots.append(pathlib.Path(prepend))
124
- roots.append(pathlib.Path(prepend) / "src")
125
- for root in roots:
126
- if not root or not root.exists():
127
- continue
128
- for p in root.iterdir():
129
- if p.is_dir() and (p / "__init__.py").exists():
130
- pkgs.append(p.name)
145
+ def _scan_module_objects(mod: object) -> None:
146
+ try:
147
+ for val in vars(mod).values():
148
+ md = getattr(val, "metadata", None) or None
149
+ if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
150
+ found.append(md)
151
+ except Exception:
152
+ pass
131
153
 
132
- for pkg_name in pkgs:
154
+ # (1) Force-load payments when enabled/forced and log explicit outcome
155
+ if WANT_PAYMENTS or FORCE_PAYMENTS:
133
156
  try:
134
- pkg = importlib.import_module(pkg_name)
135
- except Exception as e:
136
- logger.debug("Failed to import %s: %s", pkg_name, e)
137
- continue
157
+ importlib.import_module("svc_infra.apf_payments.models")
158
+ context.config.print_stdout("[alembic env] payments module import: ok (svc_infra.apf_payments.models)")
159
+ except Exception:
160
+ context.config.print_stdout("[alembic env] payments module import: ERR (svc_infra.apf_payments.models)")
161
+ context.config.print_stdout(traceback.format_exc())
162
+
163
+ # (2) seed list
164
+ pkgs: list[str] = []
165
+ # add payments package ONLY when enabled/forced
166
+ if WANT_PAYMENTS or FORCE_PAYMENTS:
167
+ pkgs.append("svc_infra.apf_payments.models")
168
+
169
+ for p in list(DISCOVER_PACKAGES or []):
170
+ if p and p not in pkgs:
171
+ pkgs.append(p)
172
+
173
+ env_pkgs = os.getenv("ALEMBIC_DISCOVER_PACKAGES", "")
174
+ if env_pkgs:
175
+ for p in (x.strip() for x in env_pkgs.split(",") if x.strip()):
176
+ if p not in pkgs:
177
+ pkgs.append(p)
178
+
179
+ # (3) filesystem discovery (root + src) – appended, not shadowing site-packages
180
+ fs_roots: list[pathlib.Path] = []
181
+ for candidate in {project_root, project_root / "src"}:
182
+ if candidate.exists():
183
+ fs_roots.append(candidate)
184
+ for root in fs_roots:
185
+ for p in root.iterdir():
186
+ if p.is_dir() and (p / "__init__.py").exists():
187
+ name = p.name
188
+ if name not in pkgs:
189
+ pkgs.append(name)
138
190
 
191
+ # Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
192
+ if "models" not in pkgs:
193
+ try:
194
+ spec = getattr(importlib, "util", None)
195
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
196
+ if spec.find_spec("models") is not None:
197
+ pkgs.append("models")
198
+ except Exception:
199
+ # If discovery fails, skip adding bare 'models'
200
+ pass
201
+
202
+ def _import_and_collect(modname: str):
203
+ try:
204
+ mod = importlib.import_module(modname)
205
+ _note(modname, True, None)
206
+ except Exception:
207
+ _note(modname, False, traceback.format_exc())
208
+ return None
139
209
  for attr in ("metadata", "MetaData", "Base", "base"):
140
- obj = getattr(pkg, attr, None)
210
+ obj = getattr(mod, attr, None)
141
211
  if obj is not None:
142
212
  _maybe_add(obj)
213
+ _scan_module_objects(mod)
214
+ return mod
215
+
216
+ for pkg_name in pkgs:
217
+ pkg = _import_and_collect(pkg_name)
218
+ if pkg is None:
219
+ continue
143
220
 
144
- for subname in ("models",):
145
- try:
146
- sub = importlib.import_module(f"{pkg_name}.{subname}")
147
- for attr in ("metadata", "MetaData", "Base", "base"):
148
- obj = getattr(sub, attr, None)
149
- if obj is not None:
150
- _maybe_add(obj)
151
- except Exception:
152
- pass
221
+ for subname in ("models", "db", "orm", "entities"):
222
+ _import_and_collect(f"{pkg_name}.{subname}")
153
223
 
154
224
  mod_path = getattr(pkg, "__path__", None)
155
225
  if not mod_path:
156
226
  continue
157
227
  for _, name, ispkg in pkgutil.walk_packages(mod_path, prefix=pkg_name + "."):
158
- if ispkg or not any(x in name for x in (".models", ".db", ".orm", ".entities")):
228
+ if ispkg:
159
229
  continue
160
- try:
161
- mod = importlib.import_module(name)
162
- except Exception:
230
+ if not any(x in name for x in (".models", ".db", ".orm", ".entities")):
163
231
  continue
164
- for attr in ("metadata", "MetaData", "Base", "base"):
165
- obj = getattr(mod, attr, None)
166
- if obj is not None:
167
- _maybe_add(obj)
232
+ _import_and_collect(name)
168
233
 
169
- # --- AUTOBIND API KEY MODEL (if a marked User model exists) ---
234
+ # Prefer ModelBase after all imports
235
+ try:
236
+ from svc_infra.db.sql.base import ModelBase # type: ignore
237
+ mb_md = getattr(ModelBase, "metadata", None)
238
+ if mb_md is not None and getattr(mb_md, "tables", {}):
239
+ found.append(mb_md)
240
+ _note("ModelBase.metadata", True, None)
241
+ else:
242
+ _note("ModelBase.metadata(empty)", True, None)
243
+ except Exception:
244
+ _note("ModelBase import", False, traceback.format_exc())
245
+
246
+ # Optional: autobind API key model
170
247
  try:
171
248
  from svc_infra.db.sql.apikey import try_autobind_apikey_model
172
- # If you prefer gating on env, pass require_env=True and set AUTH_ENABLE_API_KEYS=1
173
249
  try_autobind_apikey_model(require_env=False)
174
- except Exception as e:
175
- logger.debug("svc-infra apikey autobind skipped: %s", e)
250
+ _note("svc_infra.db.sql.apikey.try_autobind_apikey_model", True, None)
251
+ except Exception:
252
+ _note("svc_infra.db.sql.apikey.try_autobind_apikey_model", False, traceback.format_exc())
176
253
 
177
- uniq, seen = [], set()
254
+ # De-dup MetaData objects
255
+ uniq: list[object] = []
256
+ seen: set[int] = set()
178
257
  for md in found:
258
+ try:
259
+ if not getattr(md, "tables", {}):
260
+ continue
261
+ except Exception:
262
+ continue
179
263
  if id(md) not in seen:
180
264
  seen.add(id(md))
181
265
  uniq.append(md)
266
+
267
+ total_tables = 0
268
+ try:
269
+ total_tables = sum(len(getattr(md, "tables", {})) for md in uniq)
270
+ except Exception:
271
+ pass
272
+
273
+ context.config.print_stdout(
274
+ f"[alembic env] discovered {len(uniq)} metadata objects with {total_tables} tables total"
275
+ )
276
+
277
+ if WANT_PAYMENTS and not FORCE_PAYMENTS:
278
+ saw_pay = any(any(tn in PAYMENT_TABLES for tn in md.tables.keys()) for md in uniq) if uniq else False
279
+ if not saw_pay:
280
+ context.config.print_stdout(
281
+ "[alembic env] WARNING: APF_ENABLE_PAYMENTS is set but no payments tables were discovered. "
282
+ "If you still see this, a local package named 'svc_infra' may be shadowing the installed one."
283
+ )
284
+
285
+ # If nothing, dump import attempts / first 10 tracebacks
286
+ if total_tables == 0:
287
+ context.config.print_stdout("[alembic env] import attempts (ok/err):")
288
+ for name, status in tried:
289
+ context.config.print_stdout(f" - {status:3s} {name}")
290
+ for name, tb in errors[:10]:
291
+ context.config.print_stdout(f" --- import error: {name} ---")
292
+ context.config.print_stdout(tb)
293
+
182
294
  return uniq
183
295
 
296
+
184
297
  target_metadata = _collect_metadata()
185
298
 
299
+
186
300
  def _want_include_schemas() -> bool:
187
- # allow override: alembic -x include_schemas=1
188
301
  val = _x.get("include_schemas", "") or os.getenv("ALEMBIC_INCLUDE_SCHEMAS", "")
189
- return str(val).strip() in {"1", "true", "True", "yes"}
302
+ if str(val).strip() in {"1", "true", "True", "yes"}:
303
+ return True
304
+ try:
305
+ for md in (target_metadata or []):
306
+ for t in getattr(md, "tables", {}).values():
307
+ if getattr(t, "schema", None):
308
+ return True
309
+ except Exception:
310
+ pass
311
+ return False
312
+
190
313
 
191
314
  def _system_schemas_for(url: str) -> set[str]:
192
315
  try:
@@ -203,21 +326,48 @@ def _system_schemas_for(url: str) -> set[str]:
203
326
  return {"INFORMATION_SCHEMA"}
204
327
  return set()
205
328
 
329
+
206
330
  def _include_object_factory(url: str):
207
331
  sys_schemas = _system_schemas_for(url)
332
+ skip_drops = os.getenv("ALEMBIC_SKIP_DROPS", "").lower() in {"1", "true", "yes"}
333
+ want_payments = WANT_PAYMENTS or FORCE_PAYMENTS
208
334
 
209
335
  def _include_object(obj, name, type_, reflected, compare_to):
210
- # Skip system schemas
336
+ # filter system schemas
211
337
  schema = getattr(obj, "schema", None)
212
338
  if schema and str(schema) in sys_schemas:
213
339
  return False
214
- # Always keep our version table
215
- if type_ == "table" and name == (context.get_x_argument(as_dictionary=True).get("version_table") or "alembic_version"):
340
+
341
+ # Always keep Alembic version table
342
+ version_table = (
343
+ context.get_x_argument(as_dictionary=True).get("version_table")
344
+ if hasattr(context, "get_x_argument")
345
+ else None
346
+ ) or os.getenv("ALEMBIC_VERSION_TABLE", "alembic_version")
347
+ if type_ == "table" and name == version_table:
216
348
  return True
349
+
350
+ # Guard: don't drop tables that exist in DB but aren't in metadata
351
+ if skip_drops and type_ == "table" and reflected and compare_to is None:
352
+ return False
353
+
354
+ # Payments gating: when disabled, exclude payments tables and their indexes
355
+ if not want_payments:
356
+ if type_ == "table" and name in PAYMENT_TABLES:
357
+ return False
358
+ if type_ == "index":
359
+ try:
360
+ parent = getattr(obj, "table", None)
361
+ if parent is not None and getattr(parent, "name", None) in PAYMENT_TABLES:
362
+ return False
363
+ except Exception:
364
+ pass
365
+
217
366
  return True
218
367
 
219
368
  return _include_object
220
369
 
370
+
221
371
  def run_migrations_offline() -> None:
222
372
  url = config.get_main_option("sqlalchemy.url")
223
373
  context.configure(
@@ -236,6 +386,7 @@ def run_migrations_offline() -> None:
236
386
  with context.begin_transaction():
237
387
  context.run_migrations()
238
388
 
389
+
239
390
  def run_migrations_online() -> None:
240
391
  url = config.get_main_option("sqlalchemy.url")
241
392
  engine = build_engine(url, echo=False)
@@ -255,6 +406,7 @@ def run_migrations_online() -> None:
255
406
  context.run_migrations()
256
407
  engine.dispose()
257
408
 
409
+
258
410
  if context.is_offline_mode():
259
411
  run_migrations_offline()
260
412
  else:
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from .service import SqlService
8
+
9
+
10
+ class TenantSqlService(SqlService):
11
+ """
12
+ SQL service wrapper that automatically scopes operations to a tenant.
13
+
14
+ - Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
15
+ - On create, if the model has the tenant field and it's not set in data, injects tenant_id.
16
+ """
17
+
18
+ def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
19
+ super().__init__(repo)
20
+ self.tenant_id = tenant_id
21
+ self.tenant_field = tenant_field
22
+
23
+ def _where(self) -> Sequence[Any]:
24
+ model = self.repo.model
25
+ col = getattr(model, self.tenant_field, None)
26
+ if col is None:
27
+ return []
28
+ return [col == self.tenant_id]
29
+
30
+ async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
31
+ return await self.repo.list(
32
+ session, limit=limit, offset=offset, order_by=order_by, where=self._where()
33
+ )
34
+
35
+ async def count(self, session: AsyncSession) -> int:
36
+ return await self.repo.count(session, where=self._where())
37
+
38
+ async def get(self, session: AsyncSession, id_value: Any):
39
+ return await self.repo.get(session, id_value, where=self._where())
40
+
41
+ async def create(self, session: AsyncSession, data: dict[str, Any]):
42
+ data = await self.pre_create(data)
43
+ # inject tenant_id if model supports it and value missing
44
+ if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
45
+ data[self.tenant_field] = self.tenant_id
46
+ return await self.repo.create(session, data)
47
+
48
+ async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
49
+ data = await self.pre_update(data)
50
+ return await self.repo.update(session, id_value, data, where=self._where())
51
+
52
+ async def delete(self, session: AsyncSession, id_value: Any) -> bool:
53
+ return await self.repo.delete(session, id_value, where=self._where())
54
+
55
+ async def search(
56
+ self,
57
+ session: AsyncSession,
58
+ *,
59
+ q: str,
60
+ fields: Sequence[str],
61
+ limit: int,
62
+ offset: int,
63
+ order_by=None,
64
+ ):
65
+ return await self.repo.search(
66
+ session,
67
+ q=q,
68
+ fields=fields,
69
+ limit=limit,
70
+ offset=offset,
71
+ order_by=order_by,
72
+ where=self._where(),
73
+ )
74
+
75
+ async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
76
+ return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
77
+
78
+
79
+ __all__ = ["TenantSqlService"]
svc_infra/db/sql/utils.py CHANGED
@@ -196,10 +196,17 @@ def _ensure_timeout_default(u: URL) -> URL:
196
196
  """
197
197
  Ensure a conservative connection timeout is present for libpq-based drivers.
198
198
  For psycopg/psycopg2, 'connect_timeout' is honored via the query string.
199
+ For asyncpg, timeout is set via connect_args (not query string).
199
200
  """
200
201
  backend = (u.get_backend_name() or "").lower()
201
202
  if backend not in ("postgresql", "postgres"):
202
203
  return u
204
+
205
+ # asyncpg doesn't support connect_timeout in query string - use connect_args instead
206
+ dn = (u.drivername or "").lower()
207
+ if "+asyncpg" in dn:
208
+ return u
209
+
203
210
  if "connect_timeout" in u.query:
204
211
  return u
205
212
  # Default 10s unless overridden
@@ -337,9 +344,8 @@ def _ensure_ssl_default(u: URL) -> URL:
337
344
  mode = (mode_env or "").strip()
338
345
 
339
346
  if "+asyncpg" in driver:
340
- # asyncpg: use 'ssl=true' (SQLAlchemy forwards to asyncpg)
341
- if "ssl" not in u.query:
342
- return u.set(query={**u.query, "ssl": "true"})
347
+ # asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
348
+ # Do not add ssl parameter to URL query for asyncpg
343
349
  return u
344
350
  else:
345
351
  # libpq-based drivers: use sslmode (default 'require' for hosted PG)
@@ -382,10 +388,18 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
382
388
  "Async driver URL provided but SQLAlchemy async extras are not available."
383
389
  )
384
390
 
385
- # asyncpg: honor connection timeout
391
+ # asyncpg: honor connection timeout only (NOT connect_timeout)
386
392
  if "+asyncpg" in (u.drivername or ""):
387
393
  connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
388
394
 
395
+ # asyncpg doesn't accept sslmode or ssl=true in query params
396
+ # Remove these and set ssl='require' in connect_args
397
+ if "ssl" in u.query or "sslmode" in u.query:
398
+ new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
399
+ u = u.set(query=new_query)
400
+ # Set ssl in connect_args - 'require' is safest for hosted databases
401
+ connect_args["ssl"] = "require"
402
+
389
403
  # NEW: aiomysql SSL default
390
404
  if "+aiomysql" in (u.drivername or "") and not any(
391
405
  k in u.query for k in ("ssl", "ssl_ca", "sslmode")
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import Integer
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+
6
+
7
+ class Versioned:
8
+ """Mixin for optimistic locking with integer version.
9
+
10
+ - Initialize version=1 on insert (via default=1)
11
+ - Bump version in app code before commit to detect mismatches.
12
+ """
13
+
14
+ version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
@@ -0,0 +1,71 @@
1
+ # Acceptance Matrix (A-IDs)
2
+
3
+ This document maps Acceptance scenarios (A-IDs) to endpoints, CLIs, fixtures, and seed data. Use it to drive the CI promotion gate and local `make accept` runs.
4
+
5
+ ## A0. Harness
6
+ - Stack: docker-compose.test.yml (api, db, redis)
7
+ - Makefile targets: accept, compose_up, wait, seed, down
8
+ - Tests bootstrap: tests/acceptance/conftest.py (BASE_URL), _auth.py, _seed.py, _http.py
9
+
10
+ ## A1. Security & Auth
11
+ - A1-01 Register → Verify → Login → /auth/me
12
+ - Endpoints: POST /auth/register, POST /auth/verify, POST /auth/login, GET /auth/me
13
+ - Fixtures: admin, user
14
+ - A1-02 Password policy & breach check
15
+ - Endpoints: POST /auth/register
16
+ - A1-03 Lockout escalation and cooldown
17
+ - Endpoints: POST /auth/login
18
+ - A1-04 RBAC/ABAC enforced
19
+ - Endpoints: GET /admin/*, resource GET with owner guard
20
+ - A1-05 Session list & revoke
21
+ - Endpoints: GET/DELETE /auth/sessions
22
+ - A1-06 API keys lifecycle
23
+ - Endpoints: POST/GET/DELETE /auth/api-keys, usage via Authorization header
24
+ - A1-07 MFA lifecycle
25
+ - Endpoints: /auth/mfa/*
26
+
27
+ ## A2. Rate Limiting
28
+ - A2-01 Global limit → 429 with Retry-After
29
+ - A2-02 Per-route & tenant override honored
30
+ - A2-03 Window reset
31
+
32
+ ## A3. Idempotency & Concurrency
33
+ - A3-01 Same Idempotency-Key → identical 2xx
34
+ - A3-02 Conflicting payload + same key → 409
35
+ - A3-03 Optimistic lock mismatch → 409; success increments version
36
+
37
+ ## A4. Jobs & Scheduling
38
+ - A4-01 Custom job consumed
39
+ - A4-02 Backoff & DLQ
40
+ - A4-03 Cron tick observed
41
+
42
+ ## A5. Webhooks
43
+ - A5-01 Producer → delivery (HMAC verified)
44
+ - A5-02 Retry stops on success
45
+ - A5-03 Secret rotation window accepts old+new
46
+
47
+ ## A6. Tenancy
48
+ - A6-01 tenant_id injected on create; list scoped
49
+ - A6-02 Cross-tenant → 404/403
50
+ - A6-03 Per-tenant quotas enforced
51
+
52
+ ## A7. Data Lifecycle
53
+ - A7-01 Soft delete hides; undelete restores
54
+ - A7-02 GDPR erasure steps with audit
55
+ - A7-03 Retention purge soft→hard
56
+ - A7-04 Backup verification healthy
57
+
58
+ ## A8. SLOs & Ops
59
+ - A8-01 Metrics http_server_* and db_pool_* present
60
+ - A8-02 Maintenance mode 503; circuit breaker trips/recover
61
+ - A8-03 Liveness/readiness under DB up/down
62
+
63
+ ## A9. OpenAPI & Error Contracts
64
+ - A9-01 /openapi.json valid; examples present
65
+ - A9-02 Problem+JSON conforms
66
+ - A9-03 Spectral + API Doctor pass
67
+
68
+ ## A10. CLI & DX
69
+ - A10-01 DB migrate/rollback/seed
70
+ - A10-02 Jobs runner consumes a sample job
71
+ - A10-03 SDK smoke-import and /ping