svc-infra 0.1.595__py3-none-any.whl → 0.1.706__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- 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/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -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 +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -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 +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/db/nosql/core.py
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Iterable, Sequence
|
|
4
|
+
from typing import Any, Iterable, Sequence, cast
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
from
|
|
6
|
+
try:
|
|
7
|
+
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
8
|
+
from pymongo import IndexModel
|
|
9
|
+
|
|
10
|
+
HAS_MOTOR = True
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
HAS_MOTOR = False
|
|
13
|
+
AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
|
|
14
|
+
IndexModel = Any # type: ignore[assignment, misc]
|
|
8
15
|
|
|
9
16
|
from svc_infra.db.nosql.indexes import normalize_indexes
|
|
10
|
-
from svc_infra.db.nosql.mongo.client import
|
|
17
|
+
from svc_infra.db.nosql.mongo.client import (
|
|
18
|
+
acquire_db,
|
|
19
|
+
close_mongo,
|
|
20
|
+
init_mongo,
|
|
21
|
+
ping_mongo,
|
|
22
|
+
)
|
|
11
23
|
from svc_infra.db.nosql.resource import NoSqlResource
|
|
12
24
|
from svc_infra.db.nosql.utils import (
|
|
13
25
|
get_mongo_dbname_from_env,
|
|
@@ -34,7 +46,8 @@ async def _apply_indexes(
|
|
|
34
46
|
) -> list[str]:
|
|
35
47
|
if not indexes:
|
|
36
48
|
return []
|
|
37
|
-
|
|
49
|
+
result = await db[collection].create_indexes(list(indexes))
|
|
50
|
+
return cast(list[str], result)
|
|
38
51
|
|
|
39
52
|
|
|
40
53
|
# collection + doc used to "lock" the chosen DB name for this app
|
|
@@ -48,7 +61,9 @@ async def assert_db_locked(
|
|
|
48
61
|
registry = db.client.get_database(_REG_DB)
|
|
49
62
|
await registry[_REG_COLL].create_index("service_id", unique=True)
|
|
50
63
|
|
|
51
|
-
doc = await registry[_REG_COLL].find_one(
|
|
64
|
+
doc = await registry[_REG_COLL].find_one(
|
|
65
|
+
{"service_id": service_id}, projection={"db_name": 1}
|
|
66
|
+
)
|
|
52
67
|
if doc is None:
|
|
53
68
|
await registry[_REG_COLL].insert_one(
|
|
54
69
|
{"service_id": service_id, "db_name": expected_db_name}
|
|
@@ -91,9 +106,13 @@ async def prepare_mongo(
|
|
|
91
106
|
|
|
92
107
|
expected_db = get_mongo_dbname_from_env(required=True)
|
|
93
108
|
if db.name != expected_db:
|
|
94
|
-
raise RuntimeError(
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"Connected to Mongo DB '{db.name}', but env says '{expected_db}'."
|
|
111
|
+
)
|
|
95
112
|
|
|
96
|
-
await assert_db_locked(
|
|
113
|
+
await assert_db_locked(
|
|
114
|
+
db, expected_db, service_id=service_id, allow_rebind=allow_rebind
|
|
115
|
+
)
|
|
97
116
|
|
|
98
117
|
# collections
|
|
99
118
|
colls = [r.resolved_collection() for r in resources]
|
|
@@ -109,7 +128,9 @@ async def prepare_mongo(
|
|
|
109
128
|
names = await _apply_indexes(db, collection=coll, indexes=idx_models)
|
|
110
129
|
created_idx[coll] = names
|
|
111
130
|
|
|
112
|
-
return PrepareResult(
|
|
131
|
+
return PrepareResult(
|
|
132
|
+
ok=True, created_collections=created_colls, created_indexes=created_idx
|
|
133
|
+
)
|
|
113
134
|
|
|
114
135
|
|
|
115
136
|
def setup_and_prepare(
|
svc_infra/db/nosql/indexes.py
CHANGED
|
@@ -66,7 +66,9 @@ def normalize_index(idx: Union[IndexModel, Alias]) -> IndexModel:
|
|
|
66
66
|
return IndexModel(keys, **kwargs)
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def normalize_indexes(
|
|
69
|
+
def normalize_indexes(
|
|
70
|
+
indexes: Optional[Iterable[Union[IndexModel, Alias]]],
|
|
71
|
+
) -> List[IndexModel]:
|
|
70
72
|
if not indexes:
|
|
71
73
|
return []
|
|
72
74
|
return [normalize_index(i) for i in indexes]
|
svc_infra/db/nosql/management.py
CHANGED
|
@@ -83,7 +83,7 @@ def make_document_crud_schemas(
|
|
|
83
83
|
)
|
|
84
84
|
|
|
85
85
|
# Backstop encoders in case any exotic types slip through
|
|
86
|
-
encoders = {ObjectId: str, PyObjectId: str}
|
|
86
|
+
encoders: dict[type, Any] = {ObjectId: str, PyObjectId: str}
|
|
87
87
|
if json_encoders:
|
|
88
88
|
encoders.update(json_encoders)
|
|
89
89
|
|
|
@@ -29,17 +29,17 @@ We provide four CLI commands. You can register them on your Typer app or invoke
|
|
|
29
29
|
|
|
30
30
|
### Commands
|
|
31
31
|
|
|
32
|
-
- `mongo
|
|
33
|
-
- `mongo
|
|
34
|
-
- `mongo
|
|
35
|
-
- `mongo
|
|
32
|
+
- `mongo scaffold` — create both document **and** CRUD schemas
|
|
33
|
+
- `mongo scaffold-documents` — create only the **document** model (Pydantic)
|
|
34
|
+
- `mongo scaffold-schemas` — create only the **CRUD schemas**
|
|
35
|
+
- `mongo scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
|
|
36
36
|
|
|
37
37
|
### Typical usage
|
|
38
38
|
|
|
39
39
|
#### A) Scaffold documents + schemas together
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
yourapp mongo
|
|
42
|
+
yourapp mongo scaffold \
|
|
43
43
|
--entity-name Product \
|
|
44
44
|
--documents-dir ./src/your_app/products \
|
|
45
45
|
--schemas-dir ./src/your_app/products \
|
|
@@ -57,7 +57,7 @@ src/your_app/products/schemas.py # ProductRead/ProductCreate/ProductUpdate
|
|
|
57
57
|
B) Documents only
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
yourapp mongo
|
|
60
|
+
yourapp mongo scaffold-documents \
|
|
61
61
|
--dest-dir ./src/your_app/products \
|
|
62
62
|
--entity-name Product \
|
|
63
63
|
--documents-filename product_doc.py
|
|
@@ -66,7 +66,7 @@ yourapp mongo-scaffold-documents \
|
|
|
66
66
|
C) Schemas only
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
yourapp mongo
|
|
69
|
+
yourapp mongo scaffold-schemas \
|
|
70
70
|
--dest-dir ./src/your_app/products \
|
|
71
71
|
--entity-name Product \
|
|
72
72
|
--schemas-filename product_schemas.py
|
|
@@ -75,7 +75,7 @@ yourapp mongo-scaffold-schemas \
|
|
|
75
75
|
D) Starter resources.py
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
|
-
yourapp mongo
|
|
78
|
+
yourapp mongo scaffold-resources \
|
|
79
79
|
--dest-dir ./src/your_app/mongo \
|
|
80
80
|
--filename resources.py \
|
|
81
81
|
--overwrite
|
|
@@ -131,7 +131,7 @@ There are two flavors:
|
|
|
131
131
|
A) Async, minimal (connect, create collections, apply indexes)
|
|
132
132
|
|
|
133
133
|
```bash
|
|
134
|
-
yourapp mongo
|
|
134
|
+
yourapp mongo prepare \
|
|
135
135
|
--resources your_app.mongo.resources:RESOURCES \
|
|
136
136
|
--mongo-url "$MONGO_URL" \
|
|
137
137
|
--mongo-db "$MONGO_DB"
|
|
@@ -140,7 +140,7 @@ yourapp mongo-prepare \
|
|
|
140
140
|
B) Synchronous wrapper (end-to-end convenience)
|
|
141
141
|
|
|
142
142
|
```bash
|
|
143
|
-
yourapp mongo
|
|
143
|
+
yourapp mongo setup-and-prepare \
|
|
144
144
|
--resources your_app.mongo.resources:RESOURCES \
|
|
145
145
|
--mongo-url "$MONGO_URL" \
|
|
146
146
|
--mongo-db "$MONGO_DB"
|
|
@@ -149,7 +149,7 @@ yourapp mongo-setup-and-prepare \
|
|
|
149
149
|
You can also ping connectivity:
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
|
-
yourapp mongo
|
|
152
|
+
yourapp mongo ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
|
|
153
153
|
```
|
|
154
154
|
|
|
155
155
|
Behind the scenes, preparation also locks a service ID to a DB name to prevent accidental cross-DB usage. You can pass --allow-rebind if you intentionally move environments.
|
|
@@ -430,9 +430,9 @@ NoSqlResource(
|
|
|
430
430
|
• If using explicit schemas with PyObjectId, make sure model_config.json_encoders includes {PyObjectId: str}.
|
|
431
431
|
• When using auto-schemas, we expose ObjectId-like fields as str so no custom encoder is needed.
|
|
432
432
|
• Connected to wrong DB name
|
|
433
|
-
|
|
433
|
+
• The system locks a service_id to the DB name once prepared. If you change DBs, run `mongo prepare` with --allow-rebind.
|
|
434
434
|
• Indexes not created
|
|
435
|
-
|
|
435
|
+
• Double-check RESOURCES[indexes]. Run `mongo prepare` again and inspect the output dictionary of created indexes.
|
|
436
436
|
|
|
437
437
|
⸻
|
|
438
438
|
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Any, Optional
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
try:
|
|
6
|
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
|
7
|
+
|
|
8
|
+
HAS_MOTOR = True
|
|
9
|
+
except ImportError: # pragma: no cover
|
|
10
|
+
HAS_MOTOR = False
|
|
11
|
+
AsyncIOMotorClient = Any # type: ignore[assignment, misc]
|
|
12
|
+
AsyncIOMotorDatabase = Any # type: ignore[assignment, misc]
|
|
6
13
|
|
|
7
14
|
from .settings import MongoSettings
|
|
8
15
|
|
|
@@ -10,6 +17,15 @@ _client: Optional[AsyncIOMotorClient] = None
|
|
|
10
17
|
_db: Optional[AsyncIOMotorDatabase] = None
|
|
11
18
|
|
|
12
19
|
|
|
20
|
+
def _require_motor() -> None:
|
|
21
|
+
"""Raise ImportError if motor is not installed."""
|
|
22
|
+
if not HAS_MOTOR:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"MongoDB support requires the 'motor' package. "
|
|
25
|
+
"Install with: pip install svc-infra[mongodb]"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
13
29
|
def _client_opts(cfg: MongoSettings) -> dict:
|
|
14
30
|
return {
|
|
15
31
|
"appname": cfg.appname,
|
|
@@ -20,6 +36,7 @@ def _client_opts(cfg: MongoSettings) -> dict:
|
|
|
20
36
|
|
|
21
37
|
|
|
22
38
|
async def init_mongo(cfg: MongoSettings | None = None) -> AsyncIOMotorDatabase:
|
|
39
|
+
_require_motor()
|
|
23
40
|
global _client, _db
|
|
24
41
|
cfg = cfg or MongoSettings()
|
|
25
42
|
if _client is None:
|
|
@@ -6,8 +6,12 @@ from pydantic import AnyUrl, BaseModel, Field
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class MongoSettings(BaseModel):
|
|
9
|
-
url: AnyUrl = Field(
|
|
9
|
+
url: AnyUrl = Field(
|
|
10
|
+
default_factory=lambda: os.getenv("MONGO_URL", "mongodb://localhost:27017")
|
|
11
|
+
) # type: ignore[assignment]
|
|
10
12
|
db_name: str = Field(default_factory=lambda: os.getenv("MONGO_DB", ""))
|
|
11
|
-
appname: str = Field(
|
|
13
|
+
appname: str = Field(
|
|
14
|
+
default_factory=lambda: os.getenv("MONGO_APPNAME", "svc-infra")
|
|
15
|
+
)
|
|
12
16
|
min_pool_size: int = int(os.getenv("MONGO_MIN_POOL", "0"))
|
|
13
17
|
max_pool_size: int = int(os.getenv("MONGO_MAX_POOL", "100"))
|
svc_infra/db/nosql/repository.py
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
3
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
try:
|
|
6
|
+
from bson import ObjectId
|
|
7
|
+
|
|
8
|
+
_HAS_BSON = True
|
|
9
|
+
except ModuleNotFoundError:
|
|
10
|
+
# `bson` is provided by the optional `pymongo` dependency.
|
|
11
|
+
# Keep imports working for non-mongo users/tests; runtime Mongo usage still
|
|
12
|
+
# requires installing pymongo.
|
|
13
|
+
_HAS_BSON = False
|
|
14
|
+
|
|
15
|
+
class ObjectId: # type: ignore[no-redef]
|
|
16
|
+
pass
|
|
6
17
|
|
|
7
18
|
|
|
8
19
|
class NoSqlRepository:
|
|
@@ -78,11 +89,13 @@ class NoSqlRepository:
|
|
|
78
89
|
if not parts:
|
|
79
90
|
return {}
|
|
80
91
|
if len(parts) == 1:
|
|
81
|
-
return parts[0]
|
|
92
|
+
return parts[0]
|
|
82
93
|
return {"$and": parts}
|
|
83
94
|
|
|
84
95
|
def _normalize_id_value(self, val: Any) -> Any:
|
|
85
96
|
"""If we use Mongo’s _id and a string is passed, coerce to ObjectId when possible."""
|
|
97
|
+
if not _HAS_BSON:
|
|
98
|
+
return val
|
|
86
99
|
if self.id_field == "_id" and isinstance(val, str):
|
|
87
100
|
try:
|
|
88
101
|
return ObjectId(val)
|
|
@@ -91,13 +104,14 @@ class NoSqlRepository:
|
|
|
91
104
|
return val
|
|
92
105
|
|
|
93
106
|
@staticmethod
|
|
94
|
-
def _public_doc(doc:
|
|
95
|
-
if not doc:
|
|
96
|
-
return doc
|
|
107
|
+
def _public_doc(doc: Dict[str, Any]) -> Dict[str, Any]:
|
|
97
108
|
d = dict(doc)
|
|
98
109
|
if "_id" in d and "id" not in d:
|
|
99
110
|
_id = d.pop("_id", None)
|
|
100
|
-
|
|
111
|
+
if _HAS_BSON and isinstance(_id, ObjectId):
|
|
112
|
+
d["id"] = str(_id)
|
|
113
|
+
else:
|
|
114
|
+
d["id"] = _id
|
|
101
115
|
return d
|
|
102
116
|
|
|
103
117
|
async def list(
|
|
@@ -108,7 +122,7 @@ class NoSqlRepository:
|
|
|
108
122
|
offset: int,
|
|
109
123
|
sort: Optional[List[Tuple[str, int]]] = None,
|
|
110
124
|
filter: Optional[Dict[str, Any]] = None,
|
|
111
|
-
) -> List[Dict]:
|
|
125
|
+
) -> List[Dict[str, Any]]:
|
|
112
126
|
filt = self._merge_and(self._alive_filter(), filter)
|
|
113
127
|
cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
|
|
114
128
|
if sort:
|
|
@@ -117,12 +131,14 @@ class NoSqlRepository:
|
|
|
117
131
|
|
|
118
132
|
async def count(self, db, *, filter: Optional[Dict[str, Any]] = None) -> int:
|
|
119
133
|
filt = self._merge_and(self._alive_filter(), filter)
|
|
120
|
-
return await db[self.collection_name].count_documents(filt or {})
|
|
134
|
+
return cast(int, await db[self.collection_name].count_documents(filt or {}))
|
|
121
135
|
|
|
122
136
|
async def get(self, db, id_value: Any) -> Dict | None:
|
|
123
137
|
id_value = self._normalize_id_value(id_value)
|
|
124
138
|
filt = self._merge_and(self._alive_filter(), {self.id_field: id_value})
|
|
125
139
|
doc = await db[self.collection_name].find_one(filt)
|
|
140
|
+
if doc is None:
|
|
141
|
+
return None
|
|
126
142
|
return self._public_doc(doc)
|
|
127
143
|
|
|
128
144
|
async def create(self, db, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -155,10 +171,10 @@ class NoSqlRepository:
|
|
|
155
171
|
res = await db[self.collection_name].update_one(
|
|
156
172
|
{self.id_field: id_value}, {"$set": set_ops}
|
|
157
173
|
)
|
|
158
|
-
return res.modified_count > 0
|
|
174
|
+
return cast(int, res.modified_count) > 0
|
|
159
175
|
|
|
160
176
|
res = await db[self.collection_name].delete_one({self.id_field: id_value})
|
|
161
|
-
return res.deleted_count > 0
|
|
177
|
+
return cast(int, res.deleted_count) > 0
|
|
162
178
|
|
|
163
179
|
async def search(
|
|
164
180
|
self,
|
|
@@ -169,11 +185,13 @@ class NoSqlRepository:
|
|
|
169
185
|
limit: int,
|
|
170
186
|
offset: int,
|
|
171
187
|
sort: Optional[List[Tuple[str, int]]] = None,
|
|
172
|
-
) -> List[Dict]:
|
|
188
|
+
) -> List[Dict[str, Any]]:
|
|
173
189
|
regex = {"$regex": q, "$options": "i"}
|
|
174
190
|
or_filter = [{"$or": [{f: regex} for f in fields]}] if fields else []
|
|
175
191
|
filt = (
|
|
176
|
-
self._merge_and(self._alive_filter(), *or_filter)
|
|
192
|
+
self._merge_and(self._alive_filter(), *or_filter)
|
|
193
|
+
if or_filter
|
|
194
|
+
else self._alive_filter()
|
|
177
195
|
)
|
|
178
196
|
cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
|
|
179
197
|
if sort:
|
|
@@ -184,9 +202,11 @@ class NoSqlRepository:
|
|
|
184
202
|
regex = {"$regex": q, "$options": "i"}
|
|
185
203
|
or_filter = {"$or": [{f: regex} for f in fields]} if fields else {}
|
|
186
204
|
filt = self._merge_and(self._alive_filter(), or_filter)
|
|
187
|
-
return await db[self.collection_name].count_documents(filt or {})
|
|
205
|
+
return cast(int, await db[self.collection_name].count_documents(filt or {}))
|
|
188
206
|
|
|
189
207
|
async def exists(self, db, *, where: Iterable[Dict[str, Any]]) -> bool:
|
|
190
208
|
filt = self._merge_and(self._alive_filter(), *list(where))
|
|
191
|
-
doc = await db[self.collection_name].find_one(
|
|
209
|
+
doc = await db[self.collection_name].find_one(
|
|
210
|
+
filt, projection={self.id_field: 1}
|
|
211
|
+
)
|
|
192
212
|
return doc is not None
|
svc_infra/db/nosql/resource.py
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
from typing import (
|
|
5
|
+
Any,
|
|
6
|
+
Callable,
|
|
7
|
+
Iterable,
|
|
8
|
+
Optional,
|
|
9
|
+
Sequence,
|
|
10
|
+
TYPE_CHECKING,
|
|
11
|
+
Type,
|
|
12
|
+
Union,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pymongo import IndexModel
|
|
17
|
+
else:
|
|
18
|
+
try:
|
|
19
|
+
from pymongo import IndexModel
|
|
20
|
+
except ModuleNotFoundError:
|
|
21
|
+
# Minimal runtime stub so importing svc_infra works without optional Mongo deps.
|
|
22
|
+
class IndexModel: # type: ignore[no-redef]
|
|
23
|
+
pass
|
|
7
24
|
|
|
8
25
|
|
|
9
26
|
def _snake(name: str) -> str:
|
svc_infra/db/nosql/scaffold.py
CHANGED
|
@@ -6,7 +6,9 @@ from typing import Any, Dict, Literal, Optional
|
|
|
6
6
|
from svc_infra.db.utils import normalize_dir, pascal, plural_snake, snake
|
|
7
7
|
from svc_infra.utils import ensure_init_py, render_template, write
|
|
8
8
|
|
|
9
|
-
_INIT_CONTENT_PAIRED =
|
|
9
|
+
_INIT_CONTENT_PAIRED = (
|
|
10
|
+
'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
|
|
11
|
+
)
|
|
10
12
|
_INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
|
|
11
13
|
|
|
12
14
|
|
|
@@ -46,7 +48,9 @@ def scaffold_core(
|
|
|
46
48
|
schemas_txt = render_template(
|
|
47
49
|
tmpl_dir="svc_infra.db.nosql.mongo.templates",
|
|
48
50
|
name="schemas.py.tmpl",
|
|
49
|
-
subs={
|
|
51
|
+
subs={
|
|
52
|
+
"Entity": ent
|
|
53
|
+
}, # (only if your schemas.tmpl doesn't need collection_name)
|
|
50
54
|
)
|
|
51
55
|
|
|
52
56
|
if same_dir:
|
|
@@ -103,7 +107,9 @@ def scaffold_schemas_core(
|
|
|
103
107
|
dest = normalize_dir(dest_dir)
|
|
104
108
|
ent = pascal(entity_name)
|
|
105
109
|
txt = render_template(
|
|
106
|
-
tmpl_dir="svc_infra.db.nosql.mongo.templates",
|
|
110
|
+
tmpl_dir="svc_infra.db.nosql.mongo.templates",
|
|
111
|
+
name="schemas.py.tmpl",
|
|
112
|
+
subs={"Entity": ent},
|
|
107
113
|
)
|
|
108
114
|
filename = schemas_filename or f"{snake(entity_name)}.py"
|
|
109
115
|
res = write(dest / filename, txt, overwrite)
|
svc_infra/db/nosql/service.py
CHANGED
|
@@ -43,7 +43,9 @@ class NoSqlService:
|
|
|
43
43
|
async def search(
|
|
44
44
|
self, db, *, q: str, fields: Sequence[str], limit: int, offset: int, sort=None
|
|
45
45
|
):
|
|
46
|
-
return await self.repo.search(
|
|
46
|
+
return await self.repo.search(
|
|
47
|
+
db, q=q, fields=fields, limit=limit, offset=offset, sort=sort
|
|
48
|
+
)
|
|
47
49
|
|
|
48
50
|
async def count_filtered(self, db, *, q: str, fields: Sequence[str]) -> int:
|
|
49
51
|
return await self.repo.count_filtered(db, q=q, fields=fields)
|
svc_infra/db/nosql/types.py
CHANGED
|
@@ -11,7 +11,9 @@ class PyObjectId(ObjectId):
|
|
|
11
11
|
"""Pydantic v2-compatible ObjectId type."""
|
|
12
12
|
|
|
13
13
|
@classmethod
|
|
14
|
-
def __get_pydantic_core_schema__(
|
|
14
|
+
def __get_pydantic_core_schema__(
|
|
15
|
+
cls, _source_type: Any, _handler: GetCoreSchemaHandler
|
|
16
|
+
):
|
|
15
17
|
def validate(v: Any) -> ObjectId:
|
|
16
18
|
if isinstance(v, ObjectId):
|
|
17
19
|
return v
|
|
@@ -22,4 +24,6 @@ class PyObjectId(ObjectId):
|
|
|
22
24
|
raise ValueError(f"Invalid ObjectId: {v}") from e
|
|
23
25
|
raise ValueError("ObjectId required")
|
|
24
26
|
|
|
25
|
-
return core_schema.no_info_after_validator_function(
|
|
27
|
+
return core_schema.no_info_after_validator_function(
|
|
28
|
+
validate, core_schema.any_schema()
|
|
29
|
+
)
|