svc-infra 0.1.595__py3-none-any.whl → 1.1.0__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 +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -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 +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -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 +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -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 +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -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 +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- 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 +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -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 +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -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 +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- 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 +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -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-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
|
-
from
|
|
5
|
+
from importlib import import_module
|
|
5
6
|
|
|
6
7
|
import typer
|
|
7
8
|
|
|
@@ -17,13 +18,13 @@ from svc_infra.db.sql.core import stamp as core_stamp
|
|
|
17
18
|
from svc_infra.db.sql.core import upgrade as core_upgrade
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
def apply_database_url(database_url:
|
|
21
|
+
def apply_database_url(database_url: str | None) -> None:
|
|
21
22
|
"""If provided, set SQL_URL for the current process."""
|
|
22
23
|
if database_url:
|
|
23
24
|
os.environ["SQL_URL"] = database_url
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
def _find_pkgs(with_payments, discover_packages) ->
|
|
27
|
+
def _find_pkgs(with_payments, discover_packages) -> list[str]:
|
|
27
28
|
from os import getenv
|
|
28
29
|
|
|
29
30
|
payments_enabled = (
|
|
@@ -41,12 +42,12 @@ def _find_pkgs(with_payments, discover_packages) -> List[str]:
|
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def cmd_init(
|
|
44
|
-
database_url:
|
|
45
|
+
database_url: str | None = typer.Option(
|
|
45
46
|
None,
|
|
46
47
|
help="Database URL; overrides env SQL_URL for this command. "
|
|
47
48
|
"Async vs sync is auto-detected from the URL.",
|
|
48
49
|
),
|
|
49
|
-
discover_packages:
|
|
50
|
+
discover_packages: list[str] | None = typer.Option(
|
|
50
51
|
None,
|
|
51
52
|
help="Packages to search for SQLAlchemy metadata; may pass multiple. "
|
|
52
53
|
"If omitted, automatic discovery is used.",
|
|
@@ -72,13 +73,13 @@ def cmd_init(
|
|
|
72
73
|
|
|
73
74
|
def cmd_revision(
|
|
74
75
|
message: str = typer.Option(..., "-m", "--message", help="Revision message."),
|
|
75
|
-
database_url:
|
|
76
|
+
database_url: str | None = typer.Option(
|
|
76
77
|
None, help="Database URL; overrides env for this command."
|
|
77
78
|
),
|
|
78
79
|
autogenerate: bool = typer.Option(False, help="Autogenerate migrations by comparing metadata."),
|
|
79
|
-
head:
|
|
80
|
-
branch_label:
|
|
81
|
-
version_path:
|
|
80
|
+
head: str | None = typer.Option("head", help="Set the head to base this revision on."),
|
|
81
|
+
branch_label: str | None = typer.Option(None, help="Branch label."),
|
|
82
|
+
version_path: str | None = typer.Option(None, help="Alternative versions/ path."),
|
|
82
83
|
sql: bool = typer.Option(False, help="Don't generate Python; dump SQL to stdout."),
|
|
83
84
|
):
|
|
84
85
|
"""Create a new Alembic revision, either empty or autogenerated."""
|
|
@@ -95,7 +96,7 @@ def cmd_revision(
|
|
|
95
96
|
|
|
96
97
|
def cmd_upgrade(
|
|
97
98
|
revision_target: str = typer.Argument("head", help="Target revision (default head)."),
|
|
98
|
-
database_url:
|
|
99
|
+
database_url: str | None = typer.Option(
|
|
99
100
|
None, help="Database URL; overrides env for this command."
|
|
100
101
|
),
|
|
101
102
|
):
|
|
@@ -106,7 +107,7 @@ def cmd_upgrade(
|
|
|
106
107
|
|
|
107
108
|
def cmd_downgrade(
|
|
108
109
|
revision_target: str = typer.Argument("-1", help="Target revision (default -1)."),
|
|
109
|
-
database_url:
|
|
110
|
+
database_url: str | None = typer.Option(
|
|
110
111
|
None, help="Database URL; overrides env for this command."
|
|
111
112
|
),
|
|
112
113
|
):
|
|
@@ -116,18 +117,22 @@ def cmd_downgrade(
|
|
|
116
117
|
|
|
117
118
|
|
|
118
119
|
def cmd_current(
|
|
119
|
-
database_url:
|
|
120
|
+
database_url: str | None = typer.Option(
|
|
120
121
|
None, help="Database URL; overrides env for this command."
|
|
121
122
|
),
|
|
122
123
|
verbose: bool = typer.Option(False, help="Verbose output."),
|
|
123
124
|
):
|
|
124
125
|
"""Display the current revision for each database."""
|
|
125
126
|
apply_database_url(database_url)
|
|
126
|
-
core_current(verbose=verbose)
|
|
127
|
+
result = core_current(verbose=verbose)
|
|
128
|
+
try:
|
|
129
|
+
typer.echo(json.dumps(result))
|
|
130
|
+
except Exception:
|
|
131
|
+
typer.echo(str(result))
|
|
127
132
|
|
|
128
133
|
|
|
129
134
|
def cmd_history(
|
|
130
|
-
database_url:
|
|
135
|
+
database_url: str | None = typer.Option(
|
|
131
136
|
None, help="Database URL; overrides env for this command."
|
|
132
137
|
),
|
|
133
138
|
verbose: bool = typer.Option(False, help="Verbose output."),
|
|
@@ -139,7 +144,7 @@ def cmd_history(
|
|
|
139
144
|
|
|
140
145
|
def cmd_stamp(
|
|
141
146
|
revision_target: str = typer.Argument("head"),
|
|
142
|
-
database_url:
|
|
147
|
+
database_url: str | None = typer.Option(
|
|
143
148
|
None, help="Database URL; overrides env for this command."
|
|
144
149
|
),
|
|
145
150
|
):
|
|
@@ -149,10 +154,10 @@ def cmd_stamp(
|
|
|
149
154
|
|
|
150
155
|
|
|
151
156
|
def cmd_merge_heads(
|
|
152
|
-
database_url:
|
|
157
|
+
database_url: str | None = typer.Option(
|
|
153
158
|
None, help="Database URL; overrides env for this command."
|
|
154
159
|
),
|
|
155
|
-
message:
|
|
160
|
+
message: str | None = typer.Option(None, "-m", "--message", help="Merge revision message."),
|
|
156
161
|
):
|
|
157
162
|
"""Create a merge revision for multiple heads."""
|
|
158
163
|
apply_database_url(database_url)
|
|
@@ -160,7 +165,7 @@ def cmd_merge_heads(
|
|
|
160
165
|
|
|
161
166
|
|
|
162
167
|
def cmd_setup_and_migrate(
|
|
163
|
-
database_url:
|
|
168
|
+
database_url: str | None = typer.Option(
|
|
164
169
|
None,
|
|
165
170
|
help="Overrides env for this command. Async vs sync is auto-detected from the URL.",
|
|
166
171
|
),
|
|
@@ -172,7 +177,7 @@ def cmd_setup_and_migrate(
|
|
|
172
177
|
initial_message: str = typer.Option("initial schema"),
|
|
173
178
|
followup_message: str = typer.Option("autogen"),
|
|
174
179
|
# NEW:
|
|
175
|
-
discover_packages:
|
|
180
|
+
discover_packages: list[str] | None = typer.Option(
|
|
176
181
|
None,
|
|
177
182
|
help="Packages Alembic should import to discover models "
|
|
178
183
|
"(e.g. app.models,svc_infra.apf_payments.models).",
|
|
@@ -188,7 +193,7 @@ def cmd_setup_and_migrate(
|
|
|
188
193
|
Async vs. sync is inferred from SQL_URL.
|
|
189
194
|
"""
|
|
190
195
|
final_pkgs = _find_pkgs(with_payments, discover_packages)
|
|
191
|
-
core_setup_and_migrate(
|
|
196
|
+
result = core_setup_and_migrate(
|
|
192
197
|
overwrite_scaffold=overwrite_scaffold,
|
|
193
198
|
create_db_if_missing=create_db_if_missing,
|
|
194
199
|
create_followup_revision=create_followup_revision,
|
|
@@ -197,15 +202,78 @@ def cmd_setup_and_migrate(
|
|
|
197
202
|
discover_packages=final_pkgs or None,
|
|
198
203
|
database_url=database_url,
|
|
199
204
|
)
|
|
205
|
+
# Echo a concise JSON result so tests and users can introspect outcome
|
|
206
|
+
try:
|
|
207
|
+
typer.echo(json.dumps(result))
|
|
208
|
+
except Exception:
|
|
209
|
+
# Fallback to plain string if not JSON-serializable for any reason
|
|
210
|
+
typer.echo(str(result))
|
|
200
211
|
|
|
201
212
|
|
|
202
213
|
def register(app: typer.Typer) -> None:
|
|
203
|
-
app
|
|
204
|
-
app.command("
|
|
205
|
-
app.command("
|
|
206
|
-
app.command("
|
|
207
|
-
|
|
208
|
-
app.command(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
214
|
+
# Register under the 'sql' group app
|
|
215
|
+
app.command("init")(cmd_init)
|
|
216
|
+
app.command("revision")(cmd_revision)
|
|
217
|
+
app.command("upgrade")(cmd_upgrade)
|
|
218
|
+
# Allow unknown options so users can pass "-1" like Alembic without Click treating it as an option
|
|
219
|
+
app.command(
|
|
220
|
+
"downgrade",
|
|
221
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
222
|
+
)(cmd_downgrade)
|
|
223
|
+
app.command("current")(cmd_current)
|
|
224
|
+
app.command("history")(cmd_history)
|
|
225
|
+
app.command("stamp")(cmd_stamp)
|
|
226
|
+
app.command("merge-heads")(cmd_merge_heads)
|
|
227
|
+
app.command("setup-and-migrate")(cmd_setup_and_migrate)
|
|
228
|
+
app.command("seed")(cmd_seed)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _import_callable(path: str):
|
|
232
|
+
mod_name, _, fn_name = path.partition(":")
|
|
233
|
+
if not mod_name or not fn_name:
|
|
234
|
+
raise typer.BadParameter("Expected format 'module.path:callable'")
|
|
235
|
+
# Back-compat: after moving tests under tests/unit, allow legacy test module
|
|
236
|
+
# dotted paths like 'tests.db.sql.test_sql_seed_cli:my_seed'.
|
|
237
|
+
mod = None
|
|
238
|
+
unit_mod = None
|
|
239
|
+
if mod_name.startswith("tests.db."):
|
|
240
|
+
# Try legacy import first (shim module), then unit module fallback
|
|
241
|
+
try:
|
|
242
|
+
mod = import_module(mod_name)
|
|
243
|
+
except ModuleNotFoundError:
|
|
244
|
+
pass
|
|
245
|
+
unit_name = mod_name.replace("tests.db.", "tests.unit.db.", 1)
|
|
246
|
+
try:
|
|
247
|
+
unit_mod = import_module(unit_name)
|
|
248
|
+
except ModuleNotFoundError:
|
|
249
|
+
unit_mod = None
|
|
250
|
+
# If both exist, unify shared state where applicable
|
|
251
|
+
if mod is not None and unit_mod is not None:
|
|
252
|
+
# Example: tests use a global `called` dict; point legacy to unit
|
|
253
|
+
try:
|
|
254
|
+
if hasattr(unit_mod, "called"):
|
|
255
|
+
mod.called = unit_mod.called # type: ignore[attr-defined]
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
# If legacy mod missing but unit exists, use unit
|
|
259
|
+
if mod is None and unit_mod is not None:
|
|
260
|
+
mod = unit_mod
|
|
261
|
+
else:
|
|
262
|
+
mod = import_module(mod_name)
|
|
263
|
+
fn = getattr(mod, fn_name, None)
|
|
264
|
+
if not callable(fn):
|
|
265
|
+
raise typer.BadParameter(f"Callable '{fn_name}' not found in module '{mod_name}'")
|
|
266
|
+
return fn
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_seed(
|
|
270
|
+
target: str = typer.Argument(..., help="Seed callable path 'module:func'"),
|
|
271
|
+
database_url: str | None = typer.Option(
|
|
272
|
+
None,
|
|
273
|
+
help="Database URL; overrides env for this command.",
|
|
274
|
+
),
|
|
275
|
+
):
|
|
276
|
+
"""Run a user-provided seed function to load fixtures/reference data."""
|
|
277
|
+
apply_database_url(database_url)
|
|
278
|
+
fn = _import_callable(target)
|
|
279
|
+
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, cast
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
from sqlalchemy.engine import Engine
|
|
13
|
+
|
|
14
|
+
from svc_infra.db.sql.utils import build_engine
|
|
15
|
+
|
|
16
|
+
try: # SQLAlchemy async extras are optional
|
|
17
|
+
import sqlalchemy.ext.asyncio as sa_async
|
|
18
|
+
except Exception: # pragma: no cover - fallback when async extras unavailable
|
|
19
|
+
sa_async = None # type: ignore[assignment]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def export_tenant(
|
|
23
|
+
table: str = typer.Argument(..., help="Qualified table name to export (e.g., public.items)"),
|
|
24
|
+
tenant_id: str = typer.Option(..., "--tenant-id", help="Tenant id value to filter by."),
|
|
25
|
+
tenant_field: str = typer.Option("tenant_id", help="Column name for tenant id filter."),
|
|
26
|
+
output: Path | None = typer.Option(None, "--output", help="Output file; defaults to stdout."),
|
|
27
|
+
limit: int | None = typer.Option(None, help="Max rows to export."),
|
|
28
|
+
database_url: str | None = typer.Option(
|
|
29
|
+
None, "--database-url", help="Overrides env SQL_URL for this command."
|
|
30
|
+
),
|
|
31
|
+
):
|
|
32
|
+
"""Export rows for a tenant from a given SQL table as JSON array."""
|
|
33
|
+
if database_url:
|
|
34
|
+
os.environ["SQL_URL"] = database_url
|
|
35
|
+
|
|
36
|
+
url = os.getenv("SQL_URL")
|
|
37
|
+
if not url:
|
|
38
|
+
typer.echo("SQL_URL is required (or pass --database-url)", err=True)
|
|
39
|
+
raise typer.Exit(code=2)
|
|
40
|
+
|
|
41
|
+
engine = build_engine(url)
|
|
42
|
+
rows: list[dict[str, Any]]
|
|
43
|
+
query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
|
|
44
|
+
if limit and limit > 0:
|
|
45
|
+
query += " LIMIT :limit"
|
|
46
|
+
|
|
47
|
+
params: dict[str, Any] = {"tenant_id": tenant_id}
|
|
48
|
+
if limit and limit > 0:
|
|
49
|
+
params["limit"] = int(limit)
|
|
50
|
+
|
|
51
|
+
stmt = text(query)
|
|
52
|
+
|
|
53
|
+
is_async_engine = sa_async is not None and isinstance(engine, sa_async.AsyncEngine)
|
|
54
|
+
|
|
55
|
+
if is_async_engine:
|
|
56
|
+
async_engine = cast("Any", engine)
|
|
57
|
+
|
|
58
|
+
async def _fetch() -> list[dict[str, Any]]:
|
|
59
|
+
async with async_engine.connect() as conn:
|
|
60
|
+
result = await conn.execute(stmt, params)
|
|
61
|
+
return [dict(row) for row in result.mappings()]
|
|
62
|
+
|
|
63
|
+
rows = asyncio.run(_fetch())
|
|
64
|
+
else:
|
|
65
|
+
sync_engine = cast("Engine", engine)
|
|
66
|
+
with sync_engine.connect() as conn:
|
|
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)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import cast
|
|
5
5
|
|
|
6
6
|
import click
|
|
7
7
|
import typer
|
|
@@ -23,7 +23,7 @@ def cmd_scaffold(
|
|
|
23
23
|
entity_name: str = typer.Option(
|
|
24
24
|
"Item", help="Class name for entity/auth (e.g., User, Member, Product)."
|
|
25
25
|
),
|
|
26
|
-
table_name:
|
|
26
|
+
table_name: str | None = typer.Option(
|
|
27
27
|
None,
|
|
28
28
|
help="Optional table name. For kind=auth, can also be set via AUTH_TABLE_NAME; defaults to plural snake of entity.",
|
|
29
29
|
),
|
|
@@ -35,10 +35,10 @@ def cmd_scaffold(
|
|
|
35
35
|
"--same-dir/--no-same-dir",
|
|
36
36
|
help="Put models & schemas into the same dir.",
|
|
37
37
|
),
|
|
38
|
-
models_filename:
|
|
38
|
+
models_filename: str | None = typer.Option(
|
|
39
39
|
None, help="Custom filename for models (separate-dir mode)."
|
|
40
40
|
),
|
|
41
|
-
schemas_filename:
|
|
41
|
+
schemas_filename: str | None = typer.Option(
|
|
42
42
|
None, help="Custom filename for schemas (separate-dir mode)."
|
|
43
43
|
),
|
|
44
44
|
):
|
|
@@ -50,7 +50,7 @@ def cmd_scaffold(
|
|
|
50
50
|
res = scaffold_core(
|
|
51
51
|
models_dir=models_dir,
|
|
52
52
|
schemas_dir=schemas_dir,
|
|
53
|
-
kind=cast(Kind, kind.lower()),
|
|
53
|
+
kind=cast("Kind", kind.lower()),
|
|
54
54
|
entity_name=entity_name,
|
|
55
55
|
table_name=table_name,
|
|
56
56
|
overwrite=overwrite,
|
|
@@ -70,13 +70,13 @@ def cmd_scaffold_models(
|
|
|
70
70
|
click_type=click.Choice(["entity", "auth"], case_sensitive=False),
|
|
71
71
|
),
|
|
72
72
|
entity_name: str = typer.Option("Item", "--entity-name"),
|
|
73
|
-
table_name:
|
|
73
|
+
table_name: str | None = typer.Option(None, "--table-name"),
|
|
74
74
|
include_tenant: bool = typer.Option(True, "--include-tenant/--no-include-tenant"),
|
|
75
75
|
include_soft_delete: bool = typer.Option(
|
|
76
76
|
False, "--include-soft-delete/--no-include-soft-delete"
|
|
77
77
|
),
|
|
78
78
|
overwrite: bool = typer.Option(False, "--overwrite/--no-overwrite"),
|
|
79
|
-
models_filename:
|
|
79
|
+
models_filename: str | None = typer.Option(
|
|
80
80
|
None,
|
|
81
81
|
"--models-filename",
|
|
82
82
|
help="Filename to write (e.g. project_models.py). Defaults to <snake(entity)>.py",
|
|
@@ -89,7 +89,7 @@ def cmd_scaffold_models(
|
|
|
89
89
|
"""
|
|
90
90
|
res = scaffold_models_core(
|
|
91
91
|
dest_dir=dest_dir,
|
|
92
|
-
kind=cast(Kind, kind.lower()),
|
|
92
|
+
kind=cast("Kind", kind.lower()),
|
|
93
93
|
entity_name=entity_name,
|
|
94
94
|
table_name=table_name,
|
|
95
95
|
include_tenant=include_tenant,
|
|
@@ -111,7 +111,7 @@ def cmd_scaffold_schemas(
|
|
|
111
111
|
entity_name: str = typer.Option("Item", "--entity-name"),
|
|
112
112
|
include_tenant: bool = typer.Option(True, "--include-tenant/--no-include-tenant"),
|
|
113
113
|
overwrite: bool = typer.Option(False, "--overwrite/--no-overwrite"),
|
|
114
|
-
schemas_filename:
|
|
114
|
+
schemas_filename: str | None = typer.Option(
|
|
115
115
|
None,
|
|
116
116
|
"--schemas-filename",
|
|
117
117
|
help="Filename to write (e.g. project_schemas.py). Defaults to <snake(entity)>.py",
|
|
@@ -124,7 +124,7 @@ def cmd_scaffold_schemas(
|
|
|
124
124
|
"""
|
|
125
125
|
res = scaffold_schemas_core(
|
|
126
126
|
dest_dir=dest_dir,
|
|
127
|
-
kind=cast(Kind, kind.lower()),
|
|
127
|
+
kind=cast("Kind", kind.lower()),
|
|
128
128
|
entity_name=entity_name,
|
|
129
129
|
include_tenant=include_tenant,
|
|
130
130
|
overwrite=overwrite,
|
|
@@ -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)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from importlib.resources import as_file
|
|
5
|
+
from importlib.resources import files as pkg_files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import typer
|
|
10
|
+
from typer.core import TyperGroup
|
|
11
|
+
|
|
12
|
+
from svc_infra.app.root import resolve_project_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _norm(name: str) -> str:
|
|
16
|
+
return name.strip().lower().replace(" ", "-").replace("_", "-")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _discover_fs_topics(docs_dir: Path) -> dict[str, Path]:
|
|
20
|
+
topics: dict[str, Path] = {}
|
|
21
|
+
if docs_dir.exists() and docs_dir.is_dir():
|
|
22
|
+
for p in sorted(docs_dir.glob("*.md")):
|
|
23
|
+
if p.is_file():
|
|
24
|
+
topics[_norm(p.stem)] = p
|
|
25
|
+
return topics
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _discover_pkg_topics() -> dict[str, Path]:
|
|
29
|
+
"""
|
|
30
|
+
Discover docs shipped inside the installed package at svc_infra/docs/*,
|
|
31
|
+
using importlib.resources so this works for wheels, sdists, and zipped wheels.
|
|
32
|
+
"""
|
|
33
|
+
topics: dict[str, Path] = {}
|
|
34
|
+
try:
|
|
35
|
+
docs_root = pkg_files("svc_infra").joinpath("docs")
|
|
36
|
+
# docs_root is a Traversable; it may be inside a zip. Iterate safely.
|
|
37
|
+
for entry in docs_root.iterdir():
|
|
38
|
+
if entry.name.endswith(".md"):
|
|
39
|
+
# materialize to a real tempfile path if needed
|
|
40
|
+
with as_file(entry) as concrete:
|
|
41
|
+
p = Path(concrete)
|
|
42
|
+
if p.exists() and p.is_file():
|
|
43
|
+
topics[_norm(p.stem)] = p
|
|
44
|
+
except Exception:
|
|
45
|
+
# If the package has no docs directory, just return empty.
|
|
46
|
+
pass
|
|
47
|
+
return topics
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_docs_dir(ctx: click.Context) -> Path | None:
|
|
51
|
+
"""
|
|
52
|
+
Optional override precedence:
|
|
53
|
+
1) SVC_INFRA_DOCS_DIR env var
|
|
54
|
+
2) *Only when working inside the svc-infra repo itself*: repo-root /docs
|
|
55
|
+
"""
|
|
56
|
+
# 1) Env var
|
|
57
|
+
env_dir = os.getenv("SVC_INFRA_DOCS_DIR")
|
|
58
|
+
if env_dir:
|
|
59
|
+
p = Path(env_dir).expanduser()
|
|
60
|
+
if p.exists():
|
|
61
|
+
return p
|
|
62
|
+
|
|
63
|
+
# 2) In-repo convenience (so `svc-infra docs` works inside this repo)
|
|
64
|
+
try:
|
|
65
|
+
root = resolve_project_root()
|
|
66
|
+
proj_docs = root / "docs"
|
|
67
|
+
if proj_docs.exists():
|
|
68
|
+
return proj_docs
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DocsGroup(TyperGroup):
|
|
76
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
77
|
+
names: list[str] = list(super().list_commands(ctx) or [])
|
|
78
|
+
dir_to_use = _resolve_docs_dir(ctx)
|
|
79
|
+
fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
|
|
80
|
+
pkg = _discover_pkg_topics()
|
|
81
|
+
names.extend(fs.keys())
|
|
82
|
+
names.extend([k for k in pkg.keys() if k not in fs])
|
|
83
|
+
return sorted(set(names))
|
|
84
|
+
|
|
85
|
+
def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
|
|
86
|
+
cmd = super().get_command(ctx, name)
|
|
87
|
+
if cmd is not None:
|
|
88
|
+
return cmd
|
|
89
|
+
|
|
90
|
+
key = _norm(name)
|
|
91
|
+
|
|
92
|
+
dir_to_use = _resolve_docs_dir(ctx)
|
|
93
|
+
fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
|
|
94
|
+
if key in fs:
|
|
95
|
+
file_path = fs[key]
|
|
96
|
+
|
|
97
|
+
@click.command(name=name)
|
|
98
|
+
def _show_fs() -> None:
|
|
99
|
+
click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
|
|
100
|
+
|
|
101
|
+
return _show_fs
|
|
102
|
+
|
|
103
|
+
pkg = _discover_pkg_topics()
|
|
104
|
+
if key in pkg:
|
|
105
|
+
file_path = pkg[key]
|
|
106
|
+
|
|
107
|
+
@click.command(name=name)
|
|
108
|
+
def _show_pkg() -> None:
|
|
109
|
+
click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
|
|
110
|
+
|
|
111
|
+
return _show_pkg
|
|
112
|
+
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def register(app: typer.Typer) -> None:
|
|
117
|
+
docs_app = typer.Typer(no_args_is_help=True, add_completion=False, cls=DocsGroup)
|
|
118
|
+
|
|
119
|
+
@docs_app.callback(invoke_without_command=True)
|
|
120
|
+
def _docs_options() -> None:
|
|
121
|
+
# No group-level options; dynamic commands and 'show' handle topics.
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
@docs_app.command("show", help="Show docs for a topic (alternative to dynamic subcommand)")
|
|
125
|
+
def show(topic: str) -> None:
|
|
126
|
+
key = _norm(topic)
|
|
127
|
+
ctx = click.get_current_context()
|
|
128
|
+
dir_to_use = _resolve_docs_dir(ctx)
|
|
129
|
+
fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
|
|
130
|
+
if key in fs:
|
|
131
|
+
typer.echo(fs[key].read_text(encoding="utf-8", errors="replace"))
|
|
132
|
+
return
|
|
133
|
+
pkg = _discover_pkg_topics()
|
|
134
|
+
if key in pkg:
|
|
135
|
+
typer.echo(pkg[key].read_text(encoding="utf-8", errors="replace"))
|
|
136
|
+
return
|
|
137
|
+
raise typer.BadParameter(f"Unknown topic: {topic}")
|
|
138
|
+
|
|
139
|
+
app.add_typer(docs_app, name="docs")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from svc_infra.dx.changelog import Commit, generate_release_section
|
|
9
|
+
from svc_infra.dx.checks import (
|
|
10
|
+
check_migrations_up_to_date,
|
|
11
|
+
check_openapi_problem_schema,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("openapi")
|
|
18
|
+
def cmd_openapi(path: str = typer.Argument(..., help="Path to OpenAPI JSON")):
|
|
19
|
+
try:
|
|
20
|
+
check_openapi_problem_schema(path=path)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
typer.secho(f"OpenAPI check failed: {e}", fg=typer.colors.RED, err=True)
|
|
23
|
+
raise typer.Exit(2)
|
|
24
|
+
typer.secho("OpenAPI checks passed", fg=typer.colors.GREEN)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("migrations")
|
|
28
|
+
def cmd_migrations(project_root: str = typer.Option(".", help="Project root")):
|
|
29
|
+
try:
|
|
30
|
+
check_migrations_up_to_date(project_root=project_root)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
typer.secho(f"Migrations check failed: {e}", fg=typer.colors.RED, err=True)
|
|
33
|
+
raise typer.Exit(2)
|
|
34
|
+
typer.secho("Migrations checks passed", fg=typer.colors.GREEN)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("changelog")
|
|
38
|
+
def cmd_changelog(
|
|
39
|
+
version: str = typer.Argument(..., help="Version (e.g., 0.1.604)"),
|
|
40
|
+
commits_file: str = typer.Option(None, help="Path to JSON lines of commits (sha,subject)"),
|
|
41
|
+
):
|
|
42
|
+
"""Generate a changelog section from commit messages.
|
|
43
|
+
|
|
44
|
+
Expects Conventional Commits style for best grouping; falls back to Other.
|
|
45
|
+
If commits_file is omitted, prints an example format.
|
|
46
|
+
"""
|
|
47
|
+
import json
|
|
48
|
+
import sys
|
|
49
|
+
|
|
50
|
+
if not commits_file:
|
|
51
|
+
typer.echo(
|
|
52
|
+
'# Provide --commits-file with JSONL: {"sha": "<sha>", "subject": "feat: ..."}',
|
|
53
|
+
err=True,
|
|
54
|
+
)
|
|
55
|
+
raise typer.Exit(2)
|
|
56
|
+
rows = [
|
|
57
|
+
json.loads(line) for line in Path(commits_file).read_text().splitlines() if line.strip()
|
|
58
|
+
]
|
|
59
|
+
commits = [Commit(sha=r["sha"], subject=r["subject"]) for r in rows]
|
|
60
|
+
out = generate_release_section(version=version, commits=commits)
|
|
61
|
+
sys.stdout.write(out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("ci")
|
|
65
|
+
def cmd_ci(
|
|
66
|
+
run: bool = typer.Option(False, help="Execute the steps; default just prints a plan"),
|
|
67
|
+
openapi: str | None = typer.Option(None, help="Path to OpenAPI JSON to lint"),
|
|
68
|
+
project_root: str = typer.Option(".", help="Project root for migrations check"),
|
|
69
|
+
):
|
|
70
|
+
"""Print (or run) the CI steps locally to mirror the workflow."""
|
|
71
|
+
steps: list[list[str]] = []
|
|
72
|
+
# Lint, typecheck, tests
|
|
73
|
+
steps.append(["flake8", "--select=E,F"]) # mirrors CI
|
|
74
|
+
steps.append(["mypy", "src"]) # mirrors CI
|
|
75
|
+
if openapi:
|
|
76
|
+
steps.append([sys.executable, "-m", "svc_infra.cli", "dx", "openapi", openapi])
|
|
77
|
+
steps.append(
|
|
78
|
+
[
|
|
79
|
+
sys.executable,
|
|
80
|
+
"-m",
|
|
81
|
+
"svc_infra.cli",
|
|
82
|
+
"dx",
|
|
83
|
+
"migrations",
|
|
84
|
+
"--project-root",
|
|
85
|
+
project_root,
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
steps.append(["pytest", "-q", "-W", "error"]) # mirrors CI
|
|
89
|
+
|
|
90
|
+
if not run:
|
|
91
|
+
typer.echo("CI dry-run plan:")
|
|
92
|
+
for cmd in steps:
|
|
93
|
+
typer.echo(" $ " + " ".join(cmd))
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
import subprocess
|
|
97
|
+
|
|
98
|
+
for cmd in steps:
|
|
99
|
+
typer.echo("Running: " + " ".join(cmd))
|
|
100
|
+
res = subprocess.run(cmd)
|
|
101
|
+
if res.returncode != 0:
|
|
102
|
+
raise typer.Exit(res.returncode)
|
|
103
|
+
typer.echo("All CI steps passed")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(): # pragma: no cover - CLI entrypoint
|
|
107
|
+
app()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["main", "app"]
|