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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {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=
|
|
326
|
+
discover_packages=discover_packages,
|
|
327
327
|
overwrite=overwrite_scaffold,
|
|
328
328
|
)
|
|
329
329
|
versions_dir = mig_dir / "versions"
|
svc_infra/db/sql/repository.py
CHANGED
|
@@ -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()
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
97
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
svc_infra/db/sql/resource.py
CHANGED
|
@@ -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
|
+
)
|
svc_infra/db/sql/scaffold.py
CHANGED
|
@@ -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,
|
|
39
|
-
schemas_filename: Optional[str] = None,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
if s not in sys.path:
|
|
47
|
-
sys.path.insert(0, s)
|
|
51
|
+
_ensure_on_syspath_end(src_path)
|
|
48
52
|
|
|
49
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
100
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
129
|
+
if hasattr(md, "tables") and getattr(md, "tables"):
|
|
120
130
|
found.append(md)
|
|
121
131
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
141
|
+
# Force-load payments only when enabled/forced
|
|
142
|
+
if WANT_PAYMENTS or FORCE_PAYMENTS:
|
|
136
143
|
try:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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(
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
210
|
+
if ispkg:
|
|
162
211
|
continue
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|