svc-infra 0.1.600__py3-none-any.whl → 0.1.664__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/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- 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/dependencies/ratelimit.py +57 -7
- 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 +41 -6
- 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/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +21 -13
- 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 +28 -8
- svc_infra/cli/cmds/__init__.py +8 -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 +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- 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/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/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- 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 +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/docs/acceptance-matrix.md +88 -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/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +186 -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/storage.md +982 -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/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/security/permissions.py +1 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
svc_infra/cache/add.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Easy integration helper to wire the cache backend into an ASGI app lifecycle.
|
|
5
|
+
|
|
6
|
+
Contract:
|
|
7
|
+
- Idempotent: multiple calls are safe; startup/shutdown handlers are registered once.
|
|
8
|
+
- Env-driven defaults: respects CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION, APP_ENV.
|
|
9
|
+
- Lifecycle: registers startup (init + readiness probe) and shutdown (graceful close).
|
|
10
|
+
- Ergonomics: exposes the underlying cache instance at app.state.cache by default.
|
|
11
|
+
|
|
12
|
+
This does not replace the per-function decorators (`cache_read`, `cache_write`) and
|
|
13
|
+
does not alter existing direct APIs; it simply standardizes initialization and wiring.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Callable, Optional
|
|
19
|
+
|
|
20
|
+
from svc_infra.cache.backend import DEFAULT_READINESS_TIMEOUT
|
|
21
|
+
from svc_infra.cache.backend import instance as _instance
|
|
22
|
+
from svc_infra.cache.backend import setup_cache as _setup_cache
|
|
23
|
+
from svc_infra.cache.backend import shutdown_cache as _shutdown_cache
|
|
24
|
+
from svc_infra.cache.backend import wait_ready as _wait_ready
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _derive_settings(
|
|
30
|
+
url: Optional[str], prefix: Optional[str], version: Optional[str]
|
|
31
|
+
) -> tuple[str, str, str]:
|
|
32
|
+
"""Derive cache settings from parameters or environment variables.
|
|
33
|
+
|
|
34
|
+
Precedence:
|
|
35
|
+
- explicit function arguments
|
|
36
|
+
- environment variables (CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION)
|
|
37
|
+
- sensible defaults (mem://, "svc", "v1")
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
derived_url = url or os.getenv("CACHE_URL") or os.getenv("REDIS_URL") or "mem://"
|
|
41
|
+
derived_prefix = prefix or os.getenv("CACHE_PREFIX") or "svc"
|
|
42
|
+
derived_version = version or os.getenv("CACHE_VERSION") or "v1"
|
|
43
|
+
return derived_url, derived_prefix, derived_version
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def add_cache(
|
|
47
|
+
app: Any | None = None,
|
|
48
|
+
*,
|
|
49
|
+
url: str | None = None,
|
|
50
|
+
prefix: str | None = None,
|
|
51
|
+
version: str | None = None,
|
|
52
|
+
readiness_timeout: float | None = None,
|
|
53
|
+
expose_state: bool = True,
|
|
54
|
+
state_key: str = "cache",
|
|
55
|
+
) -> Callable[[], None]:
|
|
56
|
+
"""Wire cache initialization and lifecycle into the ASGI app.
|
|
57
|
+
|
|
58
|
+
If an app is provided, registers startup/shutdown handlers. Otherwise performs
|
|
59
|
+
immediate initialization (best-effort) without awaiting readiness.
|
|
60
|
+
|
|
61
|
+
Returns a no-op shutdown callable for API symmetry with other helpers.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# Compute effective settings
|
|
65
|
+
eff_url, eff_prefix, eff_version = _derive_settings(url, prefix, version)
|
|
66
|
+
|
|
67
|
+
# If no app provided, do a simple init and return
|
|
68
|
+
if app is None:
|
|
69
|
+
try:
|
|
70
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
71
|
+
logger.info(
|
|
72
|
+
"Cache initialized (no app wiring): backend=%s namespace=%s",
|
|
73
|
+
eff_url,
|
|
74
|
+
f"{eff_prefix}:{eff_version}",
|
|
75
|
+
)
|
|
76
|
+
except Exception:
|
|
77
|
+
logger.exception("Cache initialization failed (no app wiring)")
|
|
78
|
+
return lambda: None
|
|
79
|
+
|
|
80
|
+
# Idempotence: avoid duplicate wiring
|
|
81
|
+
try:
|
|
82
|
+
state = getattr(app, "state", None)
|
|
83
|
+
already = bool(getattr(state, "_svc_cache_wired", False))
|
|
84
|
+
except Exception:
|
|
85
|
+
state = None
|
|
86
|
+
already = False
|
|
87
|
+
|
|
88
|
+
if already:
|
|
89
|
+
logger.debug("add_cache: app already wired; skipping re-registration")
|
|
90
|
+
return lambda: None
|
|
91
|
+
|
|
92
|
+
# Define lifecycle handlers
|
|
93
|
+
async def _startup():
|
|
94
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
95
|
+
try:
|
|
96
|
+
await _wait_ready(timeout=readiness_timeout or DEFAULT_READINESS_TIMEOUT)
|
|
97
|
+
except Exception:
|
|
98
|
+
# Bubble up to fail fast on startup; tests and prod prefer visibility
|
|
99
|
+
logger.exception("Cache readiness probe failed during startup")
|
|
100
|
+
raise
|
|
101
|
+
# Expose cache instance for convenience
|
|
102
|
+
if expose_state and hasattr(app, "state"):
|
|
103
|
+
try:
|
|
104
|
+
setattr(app.state, state_key, _instance())
|
|
105
|
+
except Exception:
|
|
106
|
+
logger.debug("Unable to expose cache instance on app.state", exc_info=True)
|
|
107
|
+
|
|
108
|
+
async def _shutdown():
|
|
109
|
+
try:
|
|
110
|
+
await _shutdown_cache()
|
|
111
|
+
except Exception:
|
|
112
|
+
# Best-effort; shutdown should not crash the app
|
|
113
|
+
logger.debug("Cache shutdown encountered errors (ignored)", exc_info=True)
|
|
114
|
+
|
|
115
|
+
# Register event handlers when supported
|
|
116
|
+
register_ok = False
|
|
117
|
+
try:
|
|
118
|
+
if hasattr(app, "add_event_handler"):
|
|
119
|
+
app.add_event_handler("startup", _startup)
|
|
120
|
+
app.add_event_handler("shutdown", _shutdown)
|
|
121
|
+
register_ok = True
|
|
122
|
+
except Exception:
|
|
123
|
+
register_ok = False
|
|
124
|
+
|
|
125
|
+
if not register_ok:
|
|
126
|
+
# Fallback: attempt FastAPI/Starlette .on_event decorators dynamically
|
|
127
|
+
try:
|
|
128
|
+
on_event = getattr(app, "on_event", None)
|
|
129
|
+
if callable(on_event):
|
|
130
|
+
on_event("startup")(_startup) # type: ignore[misc]
|
|
131
|
+
on_event("shutdown")(_shutdown) # type: ignore[misc]
|
|
132
|
+
register_ok = True
|
|
133
|
+
except Exception:
|
|
134
|
+
register_ok = False
|
|
135
|
+
|
|
136
|
+
# Mark wired and expose state immediately if desired
|
|
137
|
+
if hasattr(app, "state"):
|
|
138
|
+
try:
|
|
139
|
+
setattr(app.state, "_svc_cache_wired", True)
|
|
140
|
+
if expose_state and not hasattr(app.state, state_key):
|
|
141
|
+
setattr(app.state, state_key, _instance())
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
if register_ok:
|
|
146
|
+
logger.info("Cache wired: url=%s namespace=%s", eff_url, f"{eff_prefix}:{eff_version}")
|
|
147
|
+
else:
|
|
148
|
+
# If we cannot register handlers, at least initialize now
|
|
149
|
+
try:
|
|
150
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
151
|
+
except Exception:
|
|
152
|
+
logger.exception("Cache initialization failed (no event registration)")
|
|
153
|
+
|
|
154
|
+
# Return a simple shutdown handle for symmetry with other add_* helpers
|
|
155
|
+
return lambda: None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = ["add_cache"]
|
svc_infra/cache/backend.py
CHANGED
|
@@ -80,9 +80,12 @@ def setup_cache(
|
|
|
80
80
|
logger.info(f"Cache version updated to: {_current_version}")
|
|
81
81
|
|
|
82
82
|
# Setup backend connection
|
|
83
|
+
# Newer cashews versions require an explicit settings_url; default to in-memory
|
|
84
|
+
# backend when no URL is provided so acceptance/unit tests work out of the box.
|
|
83
85
|
try:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
settings_url = url or "mem://"
|
|
87
|
+
setup_awaitable = _cache.setup(settings_url)
|
|
88
|
+
logger.info(f"Cache backend setup initiated with URL: {settings_url}")
|
|
86
89
|
except Exception as e:
|
|
87
90
|
logger.error(f"Failed to setup cache backend: {e}")
|
|
88
91
|
raise
|
svc_infra/cache/decorators.py
CHANGED
|
@@ -98,7 +98,25 @@ def cache_read(
|
|
|
98
98
|
ttl_val = validate_ttl(ttl)
|
|
99
99
|
template = build_key_template(key)
|
|
100
100
|
namespace = _alias() or ""
|
|
101
|
-
|
|
101
|
+
# Build a tags function that renders any templates against the call kwargs
|
|
102
|
+
base_tags_func = create_tags_function(tags)
|
|
103
|
+
|
|
104
|
+
def tags_func(*_args, **call_kwargs):
|
|
105
|
+
try:
|
|
106
|
+
raw = base_tags_func(*_args, **call_kwargs) or []
|
|
107
|
+
rendered = []
|
|
108
|
+
for t in raw:
|
|
109
|
+
if isinstance(t, str) and ("{" in t and "}" in t):
|
|
110
|
+
try:
|
|
111
|
+
rendered.append(t.format(**call_kwargs))
|
|
112
|
+
except Exception:
|
|
113
|
+
# Best effort: fall back to original
|
|
114
|
+
rendered.append(t)
|
|
115
|
+
else:
|
|
116
|
+
rendered.append(t)
|
|
117
|
+
return rendered
|
|
118
|
+
except Exception:
|
|
119
|
+
return raw if isinstance(raw, list) else []
|
|
102
120
|
|
|
103
121
|
def _decorator(func: Callable[..., Awaitable[Any]]):
|
|
104
122
|
# Try different cashews cache decorator signatures for compatibility
|
svc_infra/cache/keys.py
CHANGED
|
@@ -88,12 +88,32 @@ def build_key_variants_renderer(template: str) -> Callable[..., list[str]]:
|
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
def resolve_tags(tags, *args, **kwargs) -> list[str]:
|
|
91
|
-
"""Resolve tags from static list or callable.
|
|
91
|
+
"""Resolve tags from static list or callable and render templates with kwargs.
|
|
92
|
+
|
|
93
|
+
Supports entries like "thing:{id}" which will be formatted using provided kwargs.
|
|
94
|
+
Non-string items are passed through as str(). Missing keys are skipped with a warning.
|
|
95
|
+
"""
|
|
92
96
|
try:
|
|
97
|
+
# 1) Obtain raw tags list
|
|
93
98
|
if callable(tags):
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
raw = tags(*args, **kwargs)
|
|
100
|
+
raw_list = list(raw) if raw is not None else []
|
|
101
|
+
else:
|
|
102
|
+
raw_list = list(tags)
|
|
103
|
+
|
|
104
|
+
# 2) Render any templates using kwargs
|
|
105
|
+
rendered: list[str] = []
|
|
106
|
+
for t in raw_list:
|
|
107
|
+
try:
|
|
108
|
+
if isinstance(t, str) and ("{" in t and "}" in t):
|
|
109
|
+
rendered.append(t.format(**kwargs))
|
|
110
|
+
else:
|
|
111
|
+
rendered.append(str(t))
|
|
112
|
+
except KeyError as e:
|
|
113
|
+
logger.warning(f"Tag template missing key {e} in '{t}'")
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.warning(f"Failed to render tag '{t}': {e}")
|
|
116
|
+
return [r for r in rendered if r]
|
|
97
117
|
except Exception as e:
|
|
98
118
|
logger.error(f"Failed to resolve cache tags: {e}")
|
|
99
119
|
return []
|
svc_infra/cli/__init__.py
CHANGED
|
@@ -6,9 +6,13 @@ from svc_infra.cli.cmds import (
|
|
|
6
6
|
_HELP,
|
|
7
7
|
jobs_app,
|
|
8
8
|
register_alembic,
|
|
9
|
+
register_docs,
|
|
10
|
+
register_dx,
|
|
9
11
|
register_mongo,
|
|
10
12
|
register_mongo_scaffold,
|
|
11
13
|
register_obs,
|
|
14
|
+
register_sdk,
|
|
15
|
+
register_sql_export,
|
|
12
16
|
register_sql_scaffold,
|
|
13
17
|
)
|
|
14
18
|
from svc_infra.cli.foundation.typer_bootstrap import pre_cli
|
|
@@ -16,20 +20,36 @@ from svc_infra.cli.foundation.typer_bootstrap import pre_cli
|
|
|
16
20
|
app = typer.Typer(no_args_is_help=True, add_completion=False, help=_HELP)
|
|
17
21
|
pre_cli(app)
|
|
18
22
|
|
|
19
|
-
# --- sql
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
# --- sql group ---
|
|
24
|
+
sql_app = typer.Typer(no_args_is_help=True, add_completion=False, help="SQL commands")
|
|
25
|
+
register_alembic(sql_app)
|
|
26
|
+
register_sql_scaffold(sql_app)
|
|
27
|
+
register_sql_export(sql_app)
|
|
28
|
+
app.add_typer(sql_app, name="sql")
|
|
22
29
|
|
|
23
|
-
# ---
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
# --- mongo group ---
|
|
31
|
+
mongo_app = typer.Typer(no_args_is_help=True, add_completion=False, help="MongoDB commands")
|
|
32
|
+
register_mongo(mongo_app)
|
|
33
|
+
register_mongo_scaffold(mongo_app)
|
|
34
|
+
app.add_typer(mongo_app, name="mongo")
|
|
26
35
|
|
|
27
|
-
# --
|
|
28
|
-
|
|
36
|
+
# -- obs group ---
|
|
37
|
+
obs_app = typer.Typer(no_args_is_help=True, add_completion=False, help="Observability commands")
|
|
38
|
+
register_obs(obs_app)
|
|
39
|
+
app.add_typer(obs_app, name="obs")
|
|
40
|
+
|
|
41
|
+
# -- dx commands ---
|
|
42
|
+
register_dx(app)
|
|
29
43
|
|
|
30
44
|
# -- jobs commands ---
|
|
31
45
|
app.add_typer(jobs_app, name="jobs")
|
|
32
46
|
|
|
47
|
+
# -- sdk commands ---
|
|
48
|
+
register_sdk(app)
|
|
49
|
+
|
|
50
|
+
# -- docs commands ---
|
|
51
|
+
register_docs(app)
|
|
52
|
+
|
|
33
53
|
|
|
34
54
|
def main():
|
|
35
55
|
app()
|
svc_infra/cli/cmds/__init__.py
CHANGED
|
@@ -3,18 +3,26 @@ from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
|
|
|
3
3
|
register as register_mongo_scaffold,
|
|
4
4
|
)
|
|
5
5
|
from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
|
|
6
|
+
from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
|
|
6
7
|
from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import register as register_sql_scaffold
|
|
8
|
+
from svc_infra.cli.cmds.docs.docs_cmds import register as register_docs
|
|
9
|
+
from svc_infra.cli.cmds.dx import register_dx
|
|
7
10
|
from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
|
|
8
11
|
from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
|
|
12
|
+
from svc_infra.cli.cmds.sdk.sdk_cmds import register as register_sdk
|
|
9
13
|
|
|
10
14
|
from .help import _HELP
|
|
11
15
|
|
|
12
16
|
__all__ = [
|
|
13
17
|
"register_alembic",
|
|
14
18
|
"register_sql_scaffold",
|
|
19
|
+
"register_sql_export",
|
|
15
20
|
"register_mongo",
|
|
16
21
|
"register_mongo_scaffold",
|
|
17
22
|
"register_obs",
|
|
18
23
|
"jobs_app",
|
|
24
|
+
"register_sdk",
|
|
25
|
+
"register_dx",
|
|
26
|
+
"register_docs",
|
|
19
27
|
"_HELP",
|
|
20
28
|
]
|
|
@@ -188,6 +188,7 @@ def cmd_ping(
|
|
|
188
188
|
|
|
189
189
|
|
|
190
190
|
def register(app: typer.Typer) -> None:
|
|
191
|
-
app
|
|
192
|
-
app.command("
|
|
193
|
-
app.command("
|
|
191
|
+
# Attach to 'mongo' group app
|
|
192
|
+
app.command("prepare")(cmd_prepare)
|
|
193
|
+
app.command("setup-and-prepare")(cmd_setup_and_prepare)
|
|
194
|
+
app.command("ping")(cmd_ping)
|
|
@@ -127,7 +127,7 @@ def register(app: typer.Typer) -> None:
|
|
|
127
127
|
• mongo-scaffold-schemas
|
|
128
128
|
• mongo-scaffold-resources
|
|
129
129
|
"""
|
|
130
|
-
app.command("
|
|
131
|
-
app.command("
|
|
132
|
-
app.command("
|
|
133
|
-
app.command("
|
|
130
|
+
app.command("scaffold")(cmd_scaffold)
|
|
131
|
+
app.command("scaffold-documents")(cmd_scaffold_documents)
|
|
132
|
+
app.command("scaffold-schemas")(cmd_scaffold_schemas)
|
|
133
|
+
app.command("scaffold-resources")(cmd_scaffold_resources)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
5
|
+
from importlib import import_module
|
|
4
6
|
from typing import List, Optional
|
|
5
7
|
|
|
6
8
|
import typer
|
|
@@ -123,7 +125,11 @@ def cmd_current(
|
|
|
123
125
|
):
|
|
124
126
|
"""Display the current revision for each database."""
|
|
125
127
|
apply_database_url(database_url)
|
|
126
|
-
core_current(verbose=verbose)
|
|
128
|
+
result = core_current(verbose=verbose)
|
|
129
|
+
try:
|
|
130
|
+
typer.echo(json.dumps(result))
|
|
131
|
+
except Exception:
|
|
132
|
+
typer.echo(str(result))
|
|
127
133
|
|
|
128
134
|
|
|
129
135
|
def cmd_history(
|
|
@@ -188,7 +194,7 @@ def cmd_setup_and_migrate(
|
|
|
188
194
|
Async vs. sync is inferred from SQL_URL.
|
|
189
195
|
"""
|
|
190
196
|
final_pkgs = _find_pkgs(with_payments, discover_packages)
|
|
191
|
-
core_setup_and_migrate(
|
|
197
|
+
result = core_setup_and_migrate(
|
|
192
198
|
overwrite_scaffold=overwrite_scaffold,
|
|
193
199
|
create_db_if_missing=create_db_if_missing,
|
|
194
200
|
create_followup_revision=create_followup_revision,
|
|
@@ -197,15 +203,78 @@ def cmd_setup_and_migrate(
|
|
|
197
203
|
discover_packages=final_pkgs or None,
|
|
198
204
|
database_url=database_url,
|
|
199
205
|
)
|
|
206
|
+
# Echo a concise JSON result so tests and users can introspect outcome
|
|
207
|
+
try:
|
|
208
|
+
typer.echo(json.dumps(result))
|
|
209
|
+
except Exception:
|
|
210
|
+
# Fallback to plain string if not JSON-serializable for any reason
|
|
211
|
+
typer.echo(str(result))
|
|
200
212
|
|
|
201
213
|
|
|
202
214
|
def register(app: typer.Typer) -> None:
|
|
203
|
-
app
|
|
204
|
-
app.command("
|
|
205
|
-
app.command("
|
|
206
|
-
app.command("
|
|
207
|
-
|
|
208
|
-
app.command(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
# Register under the 'sql' group app
|
|
216
|
+
app.command("init")(cmd_init)
|
|
217
|
+
app.command("revision")(cmd_revision)
|
|
218
|
+
app.command("upgrade")(cmd_upgrade)
|
|
219
|
+
# Allow unknown options so users can pass "-1" like Alembic without Click treating it as an option
|
|
220
|
+
app.command(
|
|
221
|
+
"downgrade",
|
|
222
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
223
|
+
)(cmd_downgrade)
|
|
224
|
+
app.command("current")(cmd_current)
|
|
225
|
+
app.command("history")(cmd_history)
|
|
226
|
+
app.command("stamp")(cmd_stamp)
|
|
227
|
+
app.command("merge-heads")(cmd_merge_heads)
|
|
228
|
+
app.command("setup-and-migrate")(cmd_setup_and_migrate)
|
|
229
|
+
app.command("seed")(cmd_seed)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _import_callable(path: str):
|
|
233
|
+
mod_name, _, fn_name = path.partition(":")
|
|
234
|
+
if not mod_name or not fn_name:
|
|
235
|
+
raise typer.BadParameter("Expected format 'module.path:callable'")
|
|
236
|
+
# Back-compat: after moving tests under tests/unit, allow legacy test module
|
|
237
|
+
# dotted paths like 'tests.db.sql.test_sql_seed_cli:my_seed'.
|
|
238
|
+
mod = None
|
|
239
|
+
unit_mod = None
|
|
240
|
+
if mod_name.startswith("tests.db."):
|
|
241
|
+
# Try legacy import first (shim module), then unit module fallback
|
|
242
|
+
try:
|
|
243
|
+
mod = import_module(mod_name)
|
|
244
|
+
except ModuleNotFoundError:
|
|
245
|
+
pass
|
|
246
|
+
unit_name = mod_name.replace("tests.db.", "tests.unit.db.", 1)
|
|
247
|
+
try:
|
|
248
|
+
unit_mod = import_module(unit_name)
|
|
249
|
+
except ModuleNotFoundError:
|
|
250
|
+
unit_mod = None
|
|
251
|
+
# If both exist, unify shared state where applicable
|
|
252
|
+
if mod is not None and unit_mod is not None:
|
|
253
|
+
# Example: tests use a global `called` dict; point legacy to unit
|
|
254
|
+
try:
|
|
255
|
+
if hasattr(unit_mod, "called"):
|
|
256
|
+
setattr(mod, "called", getattr(unit_mod, "called"))
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
# If legacy mod missing but unit exists, use unit
|
|
260
|
+
if mod is None and unit_mod is not None:
|
|
261
|
+
mod = unit_mod
|
|
262
|
+
else:
|
|
263
|
+
mod = import_module(mod_name)
|
|
264
|
+
fn = getattr(mod, fn_name, None)
|
|
265
|
+
if not callable(fn):
|
|
266
|
+
raise typer.BadParameter(f"Callable '{fn_name}' not found in module '{mod_name}'")
|
|
267
|
+
return fn
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_seed(
|
|
271
|
+
target: str = typer.Argument(..., help="Seed callable path 'module:func'"),
|
|
272
|
+
database_url: Optional[str] = typer.Option(
|
|
273
|
+
None,
|
|
274
|
+
help="Database URL; overrides env for this command.",
|
|
275
|
+
),
|
|
276
|
+
):
|
|
277
|
+
"""Run a user-provided seed function to load fixtures/reference data."""
|
|
278
|
+
apply_database_url(database_url)
|
|
279
|
+
fn = _import_callable(target)
|
|
280
|
+
fn()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
|
|
13
|
+
from svc_infra.db.sql.utils import build_engine
|
|
14
|
+
|
|
15
|
+
try: # SQLAlchemy async extras are optional
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
17
|
+
except Exception: # pragma: no cover - fallback when async extras unavailable
|
|
18
|
+
AsyncEngine = None # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def export_tenant(
|
|
22
|
+
table: str = typer.Argument(..., help="Qualified table name to export (e.g., public.items)"),
|
|
23
|
+
tenant_id: str = typer.Option(..., "--tenant-id", help="Tenant id value to filter by."),
|
|
24
|
+
tenant_field: str = typer.Option("tenant_id", help="Column name for tenant id filter."),
|
|
25
|
+
output: Optional[Path] = typer.Option(
|
|
26
|
+
None, "--output", help="Output file; defaults to stdout."
|
|
27
|
+
),
|
|
28
|
+
limit: Optional[int] = typer.Option(None, help="Max rows to export."),
|
|
29
|
+
database_url: Optional[str] = typer.Option(
|
|
30
|
+
None, "--database-url", help="Overrides env SQL_URL for this command."
|
|
31
|
+
),
|
|
32
|
+
):
|
|
33
|
+
"""Export rows for a tenant from a given SQL table as JSON array."""
|
|
34
|
+
if database_url:
|
|
35
|
+
os.environ["SQL_URL"] = database_url
|
|
36
|
+
|
|
37
|
+
url = os.getenv("SQL_URL")
|
|
38
|
+
if not url:
|
|
39
|
+
typer.echo("SQL_URL is required (or pass --database-url)", err=True)
|
|
40
|
+
raise typer.Exit(code=2)
|
|
41
|
+
|
|
42
|
+
engine = build_engine(url)
|
|
43
|
+
rows: list[dict[str, Any]]
|
|
44
|
+
query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
|
|
45
|
+
if limit and limit > 0:
|
|
46
|
+
query += " LIMIT :limit"
|
|
47
|
+
|
|
48
|
+
params: dict[str, Any] = {"tenant_id": tenant_id}
|
|
49
|
+
if limit and limit > 0:
|
|
50
|
+
params["limit"] = int(limit)
|
|
51
|
+
|
|
52
|
+
stmt = text(query)
|
|
53
|
+
|
|
54
|
+
is_async_engine = AsyncEngine is not None and isinstance(engine, AsyncEngine)
|
|
55
|
+
|
|
56
|
+
if is_async_engine:
|
|
57
|
+
assert AsyncEngine is not None # for type checkers
|
|
58
|
+
|
|
59
|
+
async def _fetch() -> list[dict[str, Any]]:
|
|
60
|
+
async with engine.connect() as conn: # type: ignore[call-arg]
|
|
61
|
+
result = await conn.execute(stmt, params)
|
|
62
|
+
return [dict(row) for row in result.mappings()]
|
|
63
|
+
|
|
64
|
+
rows = asyncio.run(_fetch())
|
|
65
|
+
else:
|
|
66
|
+
with engine.connect() as conn: # type: ignore[attr-defined]
|
|
67
|
+
result = conn.execute(stmt, params)
|
|
68
|
+
rows = [dict(row) for row in result.mappings()]
|
|
69
|
+
|
|
70
|
+
data = json.dumps(rows, indent=2)
|
|
71
|
+
if output:
|
|
72
|
+
output.write_text(data)
|
|
73
|
+
typer.echo(str(output))
|
|
74
|
+
else:
|
|
75
|
+
sys.stdout.write(data)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def register(app_root: typer.Typer) -> None:
|
|
79
|
+
# Attach directly to the provided 'sql' group app
|
|
80
|
+
app_root.command("export-tenant")(export_tenant)
|
|
@@ -134,6 +134,6 @@ def cmd_scaffold_schemas(
|
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
def register(app: typer.Typer) -> None:
|
|
137
|
-
app.command("
|
|
138
|
-
app.command("
|
|
139
|
-
app.command("
|
|
137
|
+
app.command("scaffold")(cmd_scaffold)
|
|
138
|
+
app.command("scaffold-models")(cmd_scaffold_models)
|
|
139
|
+
app.command("scaffold-schemas")(cmd_scaffold_schemas)
|