stkit 17.0.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.
- stk/__init__.py +19 -0
- stk/__main__.py +6 -0
- stk/agent_login.py +78 -0
- stk/app.py +323 -0
- stk/checks.py +634 -0
- stk/cli/__init__.py +49 -0
- stk/cli/agent.py +267 -0
- stk/cli/base.py +79 -0
- stk/cli/database.py +199 -0
- stk/cli/develop.py +63 -0
- stk/cli/doctor.py +262 -0
- stk/cli/main.py +101 -0
- stk/cli/reports.py +296 -0
- stk/cli/smoke.py +399 -0
- stk/cli/users.py +259 -0
- stk/cli/watch.py +68 -0
- stk/extensions.py +29 -0
- stk/migrations/__init__.py +130 -0
- stk/migrations/env.py +58 -0
- stk/migrations/script.py.mako +17 -0
- stk/migrations/versions/stk_0001_initial_schema.py +213 -0
- stk/portal/views.py +34 -0
- stk/public/__init__.py +0 -0
- stk/public/models.py +0 -0
- stk/public/views.py +344 -0
- stk/scaffold/__init__.py +1 -0
- stk/scaffold/app/CLAUDE.md +65 -0
- stk/scaffold/app/Dockerfile +41 -0
- stk/scaffold/app/README.md +16 -0
- stk/scaffold/app/__app__/__init__.py +1 -0
- stk/scaffold/app/__app__/app.py +8 -0
- stk/scaffold/app/__app__/settings.py +7 -0
- stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
- stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
- stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
- stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
- stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
- stk/scaffold/app/docker-compose.yml +88 -0
- stk/scaffold/app/dockerignore +13 -0
- stk/scaffold/app/env-sample +78 -0
- stk/scaffold/app/gitignore +8 -0
- stk/scaffold/app/nginx/nginx.conf +46 -0
- stk/scaffold/app/nginx/stk.conf +36 -0
- stk/scaffold/app/postgres/init-db.sh +8 -0
- stk/scaffold/app/pyproject.toml +23 -0
- stk/scaffold/app/tests/__init__.py +0 -0
- stk/scaffold/app/tests/test_app.py +23 -0
- stk/scaffold/generator.py +209 -0
- stk/scaffold/templates.py +437 -0
- stk/security.py +145 -0
- stk/settings.py +147 -0
- stk/shell.py +284 -0
- stk/static/VERSIONS.txt +4 -0
- stk/static/css/app.css +229 -0
- stk/static/css/layout.css +330 -0
- stk/static/css/vuetify.min.css +6 -0
- stk/static/icons/fonts/tabler-icons.woff2 +0 -0
- stk/static/icons/tabler-icons.min.css +4 -0
- stk/static/img/auth-bg.webp +0 -0
- stk/static/img/favicon.ico +0 -0
- stk/static/img/stk.svg +1664 -0
- stk/static/js/axios.min.js +5 -0
- stk/static/js/components/NotificationDropdown.js +156 -0
- stk/static/js/components/ThemeSwitcher.js +68 -0
- stk/static/js/components/TransitionExpand.js +64 -0
- stk/static/js/components/VerticalNavGroup.js +119 -0
- stk/static/js/components/VerticalNavLink.js +36 -0
- stk/static/js/components/VerticalNavSectionTitle.js +21 -0
- stk/static/js/components/index.js +47 -0
- stk/static/js/config.js +150 -0
- stk/static/js/vue.min.js +13 -0
- stk/static/js/vuetify.min.js +3148 -0
- stk/static/robots.txt +1 -0
- stk/tasks/__init__.py +78 -0
- stk/templates/401.html +14 -0
- stk/templates/404.html +12 -0
- stk/templates/500.html +13 -0
- stk/templates/_brand.html +1 -0
- stk/templates/auth_layout.html +26 -0
- stk/templates/cms/activities.html +135 -0
- stk/templates/cms/roles.html +215 -0
- stk/templates/cms/users.html +312 -0
- stk/templates/dashboard.html +132 -0
- stk/templates/index.html +52 -0
- stk/templates/layout.html +328 -0
- stk/templates/login-layout.html +54 -0
- stk/templates/security/_macros.html +39 -0
- stk/templates/security/_messages.html +12 -0
- stk/templates/security/change_password.html +100 -0
- stk/templates/security/confirm_email.html +44 -0
- stk/templates/security/email/change_notice.html +4 -0
- stk/templates/security/email/change_notice.txt +5 -0
- stk/templates/security/email/confirmation_instructions.html +3 -0
- stk/templates/security/email/confirmation_instructions.txt +3 -0
- stk/templates/security/email/login_instructions.html +5 -0
- stk/templates/security/email/login_instructions.txt +5 -0
- stk/templates/security/email/reset_instructions.html +1 -0
- stk/templates/security/email/reset_instructions.txt +3 -0
- stk/templates/security/email/reset_notice.html +1 -0
- stk/templates/security/email/reset_notice.txt +1 -0
- stk/templates/security/email/welcome.html +7 -0
- stk/templates/security/email/welcome.txt +7 -0
- stk/templates/security/forgot_password.html +63 -0
- stk/templates/security/login_user.html +206 -0
- stk/templates/security/mf_recovery.html +67 -0
- stk/templates/security/mf_recovery_codes.html +119 -0
- stk/templates/security/register_user.html +263 -0
- stk/templates/security/reset_password.html +53 -0
- stk/templates/security/send_confirmation.html +48 -0
- stk/templates/security/send_login.html +39 -0
- stk/templates/security/two_factor_select.html +78 -0
- stk/templates/security/two_factor_setup.html +166 -0
- stk/templates/security/two_factor_verify_code.html +71 -0
- stk/templates/security/wan_register.html +179 -0
- stk/templates/security/wan_signin.html +123 -0
- stk/templates/security/wan_verify.html +109 -0
- stk/user/__init__.py +0 -0
- stk/user/forms.py +10 -0
- stk/user/models.py +308 -0
- stk/user/views.py +339 -0
- stk/utils/email.py +32 -0
- stk/utils/ratelimit.py +67 -0
- stk/websocket.py +133 -0
- stkit-17.0.0.dist-info/METADATA +135 -0
- stkit-17.0.0.dist-info/RECORD +128 -0
- stkit-17.0.0.dist-info/WHEEL +4 -0
- stkit-17.0.0.dist-info/entry_points.txt +2 -0
- stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/cli/agent.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Agent-facing commands: inspect, verify, smoke, report, shell, scaffold."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from stk.cli.base import cli_app, console
|
|
12
|
+
from stk.cli.reports import (
|
|
13
|
+
build_commands_report,
|
|
14
|
+
build_context_report,
|
|
15
|
+
build_project_report_html,
|
|
16
|
+
build_routes_report,
|
|
17
|
+
build_verify_report,
|
|
18
|
+
render_commands_markdown,
|
|
19
|
+
)
|
|
20
|
+
from stk.cli.smoke import print_smoke_report, run_smoke, smoke_exit_code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group(name="inspect")
|
|
24
|
+
def inspect_cmd():
|
|
25
|
+
"""Inspect app structure for agents and tooling."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@inspect_cmd.command("routes")
|
|
29
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
30
|
+
def inspect_routes(as_json):
|
|
31
|
+
"""Inspect registered routes."""
|
|
32
|
+
report = build_routes_report(cli_app())
|
|
33
|
+
if as_json:
|
|
34
|
+
click.echo(json.dumps(report, indent=2))
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
for route in report:
|
|
38
|
+
methods = ",".join(route["methods"])
|
|
39
|
+
click.echo(f"{methods:12} {route['rule']} -> {route['endpoint']}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@inspect_cmd.command("context")
|
|
43
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
44
|
+
def inspect_context(as_json):
|
|
45
|
+
"""Inspect routes and models as one agent-readable contract."""
|
|
46
|
+
report = build_context_report(cli_app())
|
|
47
|
+
if as_json:
|
|
48
|
+
click.echo(json.dumps(report, indent=2))
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
click.echo(
|
|
52
|
+
f"Context: {len(report['routes'])} routes, {len(report['models'])} models"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@inspect_cmd.command("commands")
|
|
57
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
58
|
+
@click.option("--markdown", is_flag=True, help="Emit the docs CLI reference page.")
|
|
59
|
+
def inspect_commands(as_json, markdown):
|
|
60
|
+
"""Inspect every stk command and its options."""
|
|
61
|
+
report = build_commands_report(click.get_current_context().find_root())
|
|
62
|
+
if as_json:
|
|
63
|
+
click.echo(json.dumps(report, indent=2))
|
|
64
|
+
elif markdown:
|
|
65
|
+
click.echo(render_commands_markdown(report), nl=False)
|
|
66
|
+
else:
|
|
67
|
+
for command in report:
|
|
68
|
+
click.echo(f"{command['usage']:48} {command['help']}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@click.command()
|
|
72
|
+
@click.option("-c", "--command", "source", default=None, help="Run code and exit.")
|
|
73
|
+
def shell(source):
|
|
74
|
+
"""Async REPL with the app, a live DB session, and all models loaded."""
|
|
75
|
+
from stk.shell import run_shell
|
|
76
|
+
|
|
77
|
+
run_shell(cli_app(), source)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
MARKERS = {"passed": "[green]✓[/]", "failed": "[red]✗[/]", "skipped": "[yellow]-[/]"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _print_verify_report(report, detail_lines=5):
|
|
84
|
+
for check in report["checks"]:
|
|
85
|
+
console.print(f"{MARKERS[check['status']]} {check['name']}", highlight=False)
|
|
86
|
+
if check["status"] != "failed":
|
|
87
|
+
continue
|
|
88
|
+
output = check["stdout"].strip() or check["stderr"].strip()
|
|
89
|
+
for line in output.splitlines()[-detail_lines:]:
|
|
90
|
+
console.print(f" [dim]{line}[/]", highlight=False)
|
|
91
|
+
if check["remedy"]:
|
|
92
|
+
console.print(f" [cyan]→ {check['remedy']}[/]", highlight=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@click.command()
|
|
96
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
97
|
+
@click.option(
|
|
98
|
+
"-w", "--watch", is_flag=True, help="Re-run affected checks on every file save."
|
|
99
|
+
)
|
|
100
|
+
def verify(as_json, watch):
|
|
101
|
+
"""Run STK verification checks."""
|
|
102
|
+
if watch:
|
|
103
|
+
_watch_and_verify()
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
report = build_verify_report()
|
|
107
|
+
if as_json:
|
|
108
|
+
click.echo(json.dumps(report, indent=2))
|
|
109
|
+
raise click.exceptions.Exit(0 if report["status"] == "passed" else 1)
|
|
110
|
+
|
|
111
|
+
_print_verify_report(report)
|
|
112
|
+
raise click.exceptions.Exit(0 if report["status"] == "passed" else 1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _watch_and_verify(interval=0.5):
|
|
116
|
+
"""Verify now, then re-verify whatever the next edit could have broken."""
|
|
117
|
+
from stk.cli import watch as watcher
|
|
118
|
+
from stk.cli.reports import VERIFY_COMMANDS
|
|
119
|
+
|
|
120
|
+
root = Path.cwd()
|
|
121
|
+
console.print("[bold]stk verify --watch[/] [dim]ctrl-c to stop[/]\n")
|
|
122
|
+
_print_verify_report(build_verify_report())
|
|
123
|
+
seen = watcher.scan(root)
|
|
124
|
+
|
|
125
|
+
while True:
|
|
126
|
+
time.sleep(interval)
|
|
127
|
+
current = watcher.scan(root)
|
|
128
|
+
touched = watcher.changed(seen, current)
|
|
129
|
+
seen = current
|
|
130
|
+
if not touched:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
names = watcher.checks_for(touched)
|
|
134
|
+
if not names:
|
|
135
|
+
continue
|
|
136
|
+
shown = ", ".join(
|
|
137
|
+
str(path.relative_to(root)) if path.is_relative_to(root) else str(path)
|
|
138
|
+
for path in touched[:3]
|
|
139
|
+
)
|
|
140
|
+
extra = f" +{len(touched) - 3}" if len(touched) > 3 else ""
|
|
141
|
+
console.print(f"\n[dim]changed:[/] {shown}{extra}")
|
|
142
|
+
commands = [item for item in VERIFY_COMMANDS if item[0] in names]
|
|
143
|
+
_print_verify_report(build_verify_report(commands))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@click.command()
|
|
147
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
148
|
+
def smoke(as_json):
|
|
149
|
+
"""Run a real-browser smoke check against a temporary development app."""
|
|
150
|
+
report = run_smoke()
|
|
151
|
+
if as_json:
|
|
152
|
+
click.echo(json.dumps(report, indent=2))
|
|
153
|
+
else:
|
|
154
|
+
print_smoke_report(report)
|
|
155
|
+
raise click.exceptions.Exit(smoke_exit_code(report))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Named `doctor_cmd` because `stk.cli.doctor` is the module that backs it, and
|
|
159
|
+
# importing a submodule rebinds that attribute on the package.
|
|
160
|
+
@click.command("doctor")
|
|
161
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
162
|
+
def doctor_cmd(as_json):
|
|
163
|
+
"""Report the state of this checkout, and what to run for anything wrong."""
|
|
164
|
+
from stk.cli.doctor import build_doctor_report
|
|
165
|
+
|
|
166
|
+
report = build_doctor_report()
|
|
167
|
+
if as_json:
|
|
168
|
+
click.echo(json.dumps(report, indent=2))
|
|
169
|
+
raise click.exceptions.Exit(0 if report["status"] != "fail" else 1)
|
|
170
|
+
|
|
171
|
+
marks = {"ok": "[green]✓[/]", "warn": "[yellow]![/]", "fail": "[red]✗[/]"}
|
|
172
|
+
for finding in report["findings"]:
|
|
173
|
+
console.print(
|
|
174
|
+
f"{marks[finding['status']]} [bold]{finding['name']:14}[/] {finding['detail']}",
|
|
175
|
+
highlight=False,
|
|
176
|
+
)
|
|
177
|
+
if finding["remedy"]:
|
|
178
|
+
console.print(f" [cyan]→ {finding['remedy']}[/]", highlight=False)
|
|
179
|
+
|
|
180
|
+
summary = {
|
|
181
|
+
"ok": "[green]Everything is ready.[/]",
|
|
182
|
+
"warn": "[yellow]Usable, with the warnings above.[/]",
|
|
183
|
+
"fail": "[red]Fix the failures above before building.[/]",
|
|
184
|
+
}
|
|
185
|
+
console.print(f"\n{summary[report['status']]}")
|
|
186
|
+
raise click.exceptions.Exit(0 if report["status"] != "fail" else 1)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@click.command()
|
|
190
|
+
@click.option(
|
|
191
|
+
"-o",
|
|
192
|
+
"--output",
|
|
193
|
+
default="docs/stk-report.html",
|
|
194
|
+
help="HTML output path.",
|
|
195
|
+
)
|
|
196
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
197
|
+
def report(output, as_json):
|
|
198
|
+
"""Generate a static project report."""
|
|
199
|
+
routes = build_routes_report(cli_app())
|
|
200
|
+
verify_report = build_verify_report()
|
|
201
|
+
report_data = {"routes": routes, "verification": verify_report}
|
|
202
|
+
if as_json:
|
|
203
|
+
click.echo(json.dumps(report_data, indent=2))
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
html_report = build_project_report_html(routes, verify_report)
|
|
207
|
+
output_path = Path(output)
|
|
208
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
output_path.write_text(html_report)
|
|
210
|
+
console.print(f"[green]Project report written:[/] {output_path}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _run_stk(args):
|
|
214
|
+
result = subprocess.run([sys.executable, "-m", "stk", *args], check=False)
|
|
215
|
+
if result.returncode != 0:
|
|
216
|
+
raise click.ClickException(f"stk {' '.join(args)} failed")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@click.command("new")
|
|
220
|
+
@click.argument("name")
|
|
221
|
+
@click.option(
|
|
222
|
+
"--migrate/--no-migrate",
|
|
223
|
+
default=True,
|
|
224
|
+
help="Autogenerate and apply the migration for the new model.",
|
|
225
|
+
)
|
|
226
|
+
@click.option("--port", default=5000, show_default=True, help="Port used in the URL.")
|
|
227
|
+
def new_module(name, migrate, port):
|
|
228
|
+
"""Scaffold a new extension module in the app, ending at a page you can open.
|
|
229
|
+
|
|
230
|
+
NAME is a singular lowercase snake_case identifier (e.g. blog_post); the
|
|
231
|
+
page and API use its plural (/blog_posts/).
|
|
232
|
+
Writes the module package and adds it to the app's extension list.
|
|
233
|
+
"""
|
|
234
|
+
from stk.migrations import app_label
|
|
235
|
+
from stk.scaffold.generator import generate_module
|
|
236
|
+
from stk.scaffold.templates import plural
|
|
237
|
+
|
|
238
|
+
app = cli_app()
|
|
239
|
+
try:
|
|
240
|
+
actions = generate_module(name, app)
|
|
241
|
+
except (ValueError, FileExistsError, RuntimeError) as exc:
|
|
242
|
+
raise click.ClickException(str(exc)) from exc
|
|
243
|
+
|
|
244
|
+
console.print(f"\n[green]Scaffolded module:[/] [bold]{name}[/]")
|
|
245
|
+
for action in actions:
|
|
246
|
+
console.print(f" [blue]+[/] {action}")
|
|
247
|
+
|
|
248
|
+
if migrate:
|
|
249
|
+
# The app object predates the module; a fresh process imports it.
|
|
250
|
+
# Autogenerate compares against a database at its heads.
|
|
251
|
+
_run_stk(["db", "upgrade"])
|
|
252
|
+
_run_stk(["db", "revision", "-m", f"add {name}"])
|
|
253
|
+
_run_stk(["db", "upgrade"])
|
|
254
|
+
console.print(f" [blue]+[/] migration generated and applied for {name}")
|
|
255
|
+
|
|
256
|
+
url = f"http://localhost:{port}/{plural(name)}/"
|
|
257
|
+
console.print(f"\n[green]Open:[/] [bold]{url}[/] [dim](start it with: stk run)[/]")
|
|
258
|
+
console.print(
|
|
259
|
+
f"""[dim]Then:[/]
|
|
260
|
+
Shape the domain in [bold]{app_label(app)}/{name}/models.py[/]{
|
|
261
|
+
', and re-run `stk db revision -m "..." && stk db upgrade`'
|
|
262
|
+
if migrate
|
|
263
|
+
else ', then `stk db revision -m "add ' + name + '"` and `stk db upgrade`'
|
|
264
|
+
}
|
|
265
|
+
Keep it honest with [bold]stk verify --watch[/] while you edit
|
|
266
|
+
"""
|
|
267
|
+
)
|
stk/cli/base.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared CLI plumbing: finding the app, console output, the sync/async bridge."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
import stk.extensions as ext
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def project_root() -> Path:
|
|
19
|
+
"""The directory of the nearest pyproject.toml, from the working directory up."""
|
|
20
|
+
cwd = Path.cwd()
|
|
21
|
+
for directory in (cwd, *cwd.parents):
|
|
22
|
+
if (directory / "pyproject.toml").is_file():
|
|
23
|
+
return directory
|
|
24
|
+
raise click.ClickException("no pyproject.toml here or above: cd into an app")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _tool_stk() -> dict:
|
|
28
|
+
pyproject = project_root() / "pyproject.toml"
|
|
29
|
+
return tomllib.loads(pyproject.read_text()).get("tool", {}).get("stk", {})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def app_spec() -> str:
|
|
33
|
+
"""The app as `module:attribute`, from STK_APP or `[tool.stk] app`."""
|
|
34
|
+
if spec := os.environ.get("STK_APP"):
|
|
35
|
+
return spec
|
|
36
|
+
if spec := _tool_stk().get("app"):
|
|
37
|
+
return spec
|
|
38
|
+
raise click.ClickException(
|
|
39
|
+
f'{project_root() / "pyproject.toml"} has no [tool.stk] app = "package.app:app".'
|
|
40
|
+
" Start one with: uv run stk init <name>"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def app_root() -> Path:
|
|
45
|
+
"""The directory the app's package is imported from.
|
|
46
|
+
|
|
47
|
+
The project, unless `[tool.stk] path` names another: stk's own repository
|
|
48
|
+
runs its bundled example that way.
|
|
49
|
+
"""
|
|
50
|
+
path = None if os.environ.get("STK_APP") else _tool_stk().get("path")
|
|
51
|
+
return project_root() / path if path else project_root()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_app():
|
|
55
|
+
"""Import and return the app named by `app_spec()`."""
|
|
56
|
+
module_name, _, attribute = app_spec().partition(":")
|
|
57
|
+
# The project need not be installed: import it from its own directory.
|
|
58
|
+
sys.path.insert(0, str(app_root()))
|
|
59
|
+
return getattr(importlib.import_module(module_name), attribute or "app")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cli_app():
|
|
63
|
+
"""The app for this CLI invocation, loaded once."""
|
|
64
|
+
from quart.cli import ScriptInfo
|
|
65
|
+
|
|
66
|
+
return click.get_current_context().ensure_object(ScriptInfo).load_app()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_async(coro):
|
|
70
|
+
"""Run an async function in sync context (for CLI commands)."""
|
|
71
|
+
|
|
72
|
+
async def _wrapper():
|
|
73
|
+
try:
|
|
74
|
+
return await coro
|
|
75
|
+
finally:
|
|
76
|
+
if ext.engine:
|
|
77
|
+
await ext.engine.dispose()
|
|
78
|
+
|
|
79
|
+
return asyncio.run(_wrapper())
|
stk/cli/database.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Database and migration commands."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from alembic import command
|
|
5
|
+
|
|
6
|
+
from stk.cli.base import cli_app, console
|
|
7
|
+
from stk.migrations import (
|
|
8
|
+
CORE_LABEL,
|
|
9
|
+
app_label,
|
|
10
|
+
app_versions,
|
|
11
|
+
build_alembic_config,
|
|
12
|
+
get_target_metadata,
|
|
13
|
+
throwaway_database_url,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_alembic(action, *args, **kwargs):
|
|
18
|
+
"""Run an alembic command, reporting its complaint instead of a traceback."""
|
|
19
|
+
from alembic.util.exc import CommandError
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
return action(*args, **kwargs)
|
|
23
|
+
except CommandError as exc:
|
|
24
|
+
raise click.ClickException(f"{exc}\nrun: uv run stk doctor") from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _flatten(diffs):
|
|
28
|
+
for diff in diffs:
|
|
29
|
+
if isinstance(diff, list):
|
|
30
|
+
yield from diff
|
|
31
|
+
else:
|
|
32
|
+
yield diff
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _describe_diff(diff):
|
|
36
|
+
"""Render an autogenerate diff tuple as one readable line."""
|
|
37
|
+
parts = []
|
|
38
|
+
for element in diff[1:]:
|
|
39
|
+
if element is None or isinstance(element, dict): # schema, autogenerate opts
|
|
40
|
+
continue
|
|
41
|
+
parts.append(str(getattr(element, "name", element)))
|
|
42
|
+
return f"{diff[0]}: {' '.join(parts)}".strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_migration_drift_report(app=None, database_url=None):
|
|
46
|
+
"""Return differences between the migrations at their heads and the models.
|
|
47
|
+
|
|
48
|
+
Migrations run against a throwaway database of the app's dialect, so the
|
|
49
|
+
answer does not depend on the state of any developer machine. With no app,
|
|
50
|
+
stk's own lineage is checked against its own models.
|
|
51
|
+
"""
|
|
52
|
+
from alembic.autogenerate import compare_metadata
|
|
53
|
+
from alembic.runtime.migration import MigrationContext
|
|
54
|
+
from sqlalchemy import create_engine
|
|
55
|
+
|
|
56
|
+
from stk.migrations import include_object
|
|
57
|
+
|
|
58
|
+
if database_url is None:
|
|
59
|
+
database_url = app.config["SQLALCHEMY_DATABASE_URI"]
|
|
60
|
+
with throwaway_database_url(database_url, sync=True) as url:
|
|
61
|
+
config = build_alembic_config(app, url, quiet=True)
|
|
62
|
+
command.upgrade(config, "heads")
|
|
63
|
+
|
|
64
|
+
engine = create_engine(url)
|
|
65
|
+
try:
|
|
66
|
+
with engine.connect() as connection:
|
|
67
|
+
context = MigrationContext.configure(
|
|
68
|
+
connection,
|
|
69
|
+
opts={
|
|
70
|
+
"compare_type": True,
|
|
71
|
+
"compare_server_default": True,
|
|
72
|
+
"include_object": include_object,
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
diffs = compare_metadata(context, get_target_metadata())
|
|
76
|
+
finally:
|
|
77
|
+
engine.dispose()
|
|
78
|
+
|
|
79
|
+
return [_describe_diff(diff) for diff in _flatten(diffs)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def generate_revision(app, message, *, autogenerate=True, head=None, quiet=False):
|
|
83
|
+
"""Write a revision in the app's lineage, or in `head`'s when given.
|
|
84
|
+
|
|
85
|
+
The app's first revision starts its branch. Every app revision depends on
|
|
86
|
+
stk's head at the time it is written, so core tables (`user`) exist first.
|
|
87
|
+
"""
|
|
88
|
+
from alembic.script import ScriptDirectory
|
|
89
|
+
|
|
90
|
+
config = build_alembic_config(app, quiet=quiet)
|
|
91
|
+
if head is not None:
|
|
92
|
+
return run_alembic(
|
|
93
|
+
command.revision,
|
|
94
|
+
config,
|
|
95
|
+
message=message,
|
|
96
|
+
autogenerate=autogenerate,
|
|
97
|
+
head=head,
|
|
98
|
+
)
|
|
99
|
+
script = ScriptDirectory.from_config(config)
|
|
100
|
+
label = app_label(app)
|
|
101
|
+
started = any(label in rev.branch_labels for rev in script.walk_revisions())
|
|
102
|
+
core_head = script.get_revision(f"{CORE_LABEL}@head").revision
|
|
103
|
+
options = (
|
|
104
|
+
{"head": f"{label}@head"}
|
|
105
|
+
if started
|
|
106
|
+
else {
|
|
107
|
+
"head": "base",
|
|
108
|
+
"branch_label": label,
|
|
109
|
+
"version_path": str(app_versions(app)),
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
app_versions(app).mkdir(exist_ok=True)
|
|
113
|
+
return run_alembic(
|
|
114
|
+
command.revision,
|
|
115
|
+
config,
|
|
116
|
+
message=message,
|
|
117
|
+
autogenerate=autogenerate,
|
|
118
|
+
depends_on=core_head,
|
|
119
|
+
**options,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@click.command()
|
|
124
|
+
def create_db():
|
|
125
|
+
"""Apply all database migrations (same as `stk db upgrade`)."""
|
|
126
|
+
run_alembic(command.upgrade, build_alembic_config(cli_app()), "heads")
|
|
127
|
+
console.print("[green]Database migrations applied successfully[/]")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@click.group()
|
|
131
|
+
def db():
|
|
132
|
+
"""Alembic-backed database migration commands."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@db.command("upgrade")
|
|
136
|
+
@click.argument("revision", default="heads")
|
|
137
|
+
def db_upgrade(revision):
|
|
138
|
+
"""Upgrade the database: stk's migrations and the app's, to their heads."""
|
|
139
|
+
run_alembic(command.upgrade, build_alembic_config(cli_app()), revision)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Relative targets like "-1" look like options to click; accept them as the argument.
|
|
143
|
+
@db.command("downgrade", context_settings={"ignore_unknown_options": True})
|
|
144
|
+
@click.argument("revision")
|
|
145
|
+
def db_downgrade(revision):
|
|
146
|
+
"""Downgrade the database to a target revision."""
|
|
147
|
+
run_alembic(command.downgrade, build_alembic_config(cli_app()), revision)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@db.command("revision")
|
|
151
|
+
@click.option("-m", "--message", required=True, help="Revision message")
|
|
152
|
+
@click.option(
|
|
153
|
+
"--autogenerate/--empty",
|
|
154
|
+
default=True,
|
|
155
|
+
help="Autogenerate from model metadata or create an empty revision",
|
|
156
|
+
)
|
|
157
|
+
@click.option(
|
|
158
|
+
"--head",
|
|
159
|
+
default=None,
|
|
160
|
+
help="Extend this head instead of the app's lineage (stk@head for core).",
|
|
161
|
+
)
|
|
162
|
+
def db_revision(message, autogenerate, head):
|
|
163
|
+
"""Create a migration revision in the app's own lineage."""
|
|
164
|
+
generate_revision(cli_app(), message, autogenerate=autogenerate, head=head)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@db.command("check")
|
|
168
|
+
def db_check():
|
|
169
|
+
"""Fail if models have drifted away from the migrations."""
|
|
170
|
+
drift = build_migration_drift_report(cli_app())
|
|
171
|
+
if not drift:
|
|
172
|
+
console.print("[green]No migration drift: models match migrations[/]")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
console.print("[red]Migration drift detected:[/]")
|
|
176
|
+
for line in drift:
|
|
177
|
+
console.print(f" [yellow]{line}[/]")
|
|
178
|
+
raise click.ClickException(
|
|
179
|
+
'run: uv run stk db revision -m "describe change" && uv run stk db upgrade'
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@db.command("current")
|
|
184
|
+
def db_current():
|
|
185
|
+
"""Show the current database revision."""
|
|
186
|
+
run_alembic(command.current, build_alembic_config(cli_app()))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@db.command("history")
|
|
190
|
+
def db_history():
|
|
191
|
+
"""Show migration history."""
|
|
192
|
+
run_alembic(command.history, build_alembic_config(cli_app()))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@db.command("stamp")
|
|
196
|
+
@click.argument("revision", default="heads")
|
|
197
|
+
def db_stamp(revision):
|
|
198
|
+
"""Stamp a database with a revision without running migrations."""
|
|
199
|
+
run_alembic(command.stamp, build_alembic_config(cli_app()), revision)
|
stk/cli/develop.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Starting an app and serving it locally."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
import stk
|
|
8
|
+
from stk.cli.base import app_root, app_spec, cli_app, console
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command("init")
|
|
12
|
+
@click.argument("name")
|
|
13
|
+
@click.option(
|
|
14
|
+
"--source",
|
|
15
|
+
default=None,
|
|
16
|
+
help="Install stk from this checkout or wheel instead of the stkit package.",
|
|
17
|
+
)
|
|
18
|
+
def init(name, source):
|
|
19
|
+
"""Start a new app package NAME in ./NAME, depending on this stk version."""
|
|
20
|
+
from stk.scaffold.generator import generate_app
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
actions = generate_app(name, Path.cwd() / name, source)
|
|
24
|
+
except (ValueError, FileExistsError) as exc:
|
|
25
|
+
raise click.ClickException(str(exc)) from exc
|
|
26
|
+
|
|
27
|
+
for action in actions:
|
|
28
|
+
console.print(f" [blue]+[/] {action}")
|
|
29
|
+
console.print(
|
|
30
|
+
f"""
|
|
31
|
+
[green]App {name} is ready.[/] Next:
|
|
32
|
+
cd {name}
|
|
33
|
+
uv run stk db upgrade && uv run stk install
|
|
34
|
+
uv run stk run
|
|
35
|
+
"""
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.command("run")
|
|
40
|
+
@click.option("--host", default="127.0.0.1", help="The interface to bind to.")
|
|
41
|
+
@click.option("-p", "--port", default=5000, help="The port to bind to.")
|
|
42
|
+
@click.option(
|
|
43
|
+
"--reload/--no-reload",
|
|
44
|
+
default=None,
|
|
45
|
+
help="Restart on code changes (default: on when QUART_DEBUG is set).",
|
|
46
|
+
)
|
|
47
|
+
def run(host, port, reload):
|
|
48
|
+
"""Run a local development server (uvicorn, as in production)."""
|
|
49
|
+
import uvicorn
|
|
50
|
+
from quart.helpers import get_debug_flag
|
|
51
|
+
|
|
52
|
+
app = cli_app()
|
|
53
|
+
# Hypercorn, Quart's own runner, waits forever on open tabs at Ctrl+C. The
|
|
54
|
+
# timeout ends long-lived streams (/ws, SSE); browsers reconnect.
|
|
55
|
+
uvicorn.run(
|
|
56
|
+
app_spec(),
|
|
57
|
+
host=host,
|
|
58
|
+
port=port,
|
|
59
|
+
reload=get_debug_flag() if reload is None else reload,
|
|
60
|
+
reload_dirs=[app.root_path, str(Path(stk.__file__).parent)],
|
|
61
|
+
app_dir=str(app_root()),
|
|
62
|
+
timeout_graceful_shutdown=3,
|
|
63
|
+
)
|