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/doctor.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""`stk doctor`: the state of this checkout, and the next action for anything wrong.
|
|
2
|
+
|
|
3
|
+
Answers the questions you would otherwise discover one failed command at a time:
|
|
4
|
+
is the environment configured, is the database migrated, is there an admin, are
|
|
5
|
+
the vendored assets present, is the test-only login accidentally exposed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import func, select
|
|
11
|
+
|
|
12
|
+
import stk
|
|
13
|
+
import stk.extensions as ext
|
|
14
|
+
from stk.cli.base import app_spec, cli_app, project_root, run_async
|
|
15
|
+
from stk.migrations import build_alembic_config, sync_url
|
|
16
|
+
|
|
17
|
+
CORE_STATIC = Path(stk.__file__).parent / "static"
|
|
18
|
+
|
|
19
|
+
OK = "ok"
|
|
20
|
+
WARN = "warn"
|
|
21
|
+
FAIL = "fail"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _finding(name, status, detail, remedy=None):
|
|
25
|
+
return {"name": name, "status": status, "detail": detail, "remedy": remedy}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_env_file(root):
|
|
29
|
+
path = root / ".env"
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return _finding(
|
|
32
|
+
"env file",
|
|
33
|
+
FAIL,
|
|
34
|
+
".env is missing",
|
|
35
|
+
"copy .env-sample to .env, fill secrets",
|
|
36
|
+
)
|
|
37
|
+
return _finding("env file", OK, str(path.relative_to(root)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check_secrets(config):
|
|
41
|
+
weak = [
|
|
42
|
+
name
|
|
43
|
+
for name in ("SECRET_KEY", "SECURITY_PASSWORD_SALT")
|
|
44
|
+
if len(str(config.get(name) or "")) < 32
|
|
45
|
+
]
|
|
46
|
+
if weak:
|
|
47
|
+
return _finding(
|
|
48
|
+
"secrets",
|
|
49
|
+
FAIL,
|
|
50
|
+
f"weak or missing: {', '.join(weak)}",
|
|
51
|
+
"set 32+ random characters in .env",
|
|
52
|
+
)
|
|
53
|
+
return _finding("secrets", OK, "SECRET_KEY and password salt look random")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check_database(config):
|
|
57
|
+
url = config["SQLALCHEMY_DATABASE_URI"]
|
|
58
|
+
if url.startswith("sqlite"):
|
|
59
|
+
path = Path(url.split("///")[-1])
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return _finding(
|
|
62
|
+
"database", FAIL, f"{path} does not exist", "uv run stk create-db"
|
|
63
|
+
)
|
|
64
|
+
size = path.stat().st_size // 1024
|
|
65
|
+
return _finding("database", OK, f"sqlite {path.name} ({size} KiB)")
|
|
66
|
+
return _finding("database", OK, url.split("@")[-1])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _as_tuple(value):
|
|
70
|
+
return () if value is None else (value,) if isinstance(value, str) else value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _applied(script, current):
|
|
74
|
+
"""Every revision the database already has: its heads and their ancestry.
|
|
75
|
+
|
|
76
|
+
The version table holds only heads, and an app revision that depends on
|
|
77
|
+
stk's head stands in for it.
|
|
78
|
+
"""
|
|
79
|
+
applied, pending = set(), list(current)
|
|
80
|
+
while pending:
|
|
81
|
+
revision = script.get_revision(pending.pop())
|
|
82
|
+
if revision.revision not in applied:
|
|
83
|
+
applied.add(revision.revision)
|
|
84
|
+
pending += [
|
|
85
|
+
*_as_tuple(revision.down_revision),
|
|
86
|
+
*_as_tuple(revision.dependencies),
|
|
87
|
+
]
|
|
88
|
+
return applied
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _defined(script, revision):
|
|
92
|
+
try:
|
|
93
|
+
script.get_revision(revision)
|
|
94
|
+
except Exception: # alembic raises CommandError for a revision it cannot find
|
|
95
|
+
return False
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _check_migrations(app):
|
|
100
|
+
"""Compare the database revisions against the migration heads on disk."""
|
|
101
|
+
from alembic.runtime.migration import MigrationContext
|
|
102
|
+
from alembic.script import ScriptDirectory
|
|
103
|
+
from sqlalchemy import create_engine
|
|
104
|
+
|
|
105
|
+
script = ScriptDirectory.from_config(build_alembic_config(app))
|
|
106
|
+
heads = set(script.get_heads())
|
|
107
|
+
engine = create_engine(sync_url(app.config["SQLALCHEMY_DATABASE_URI"]))
|
|
108
|
+
try:
|
|
109
|
+
with engine.connect() as connection:
|
|
110
|
+
current = set(MigrationContext.configure(connection).get_current_heads())
|
|
111
|
+
except Exception as exc: # unreachable database is its own diagnosis
|
|
112
|
+
return _finding("migrations", FAIL, str(exc)[:80], "uv run stk db upgrade")
|
|
113
|
+
finally:
|
|
114
|
+
engine.dispose()
|
|
115
|
+
|
|
116
|
+
shown = ", ".join(sorted(heads))
|
|
117
|
+
if not current:
|
|
118
|
+
return _finding(
|
|
119
|
+
"migrations", FAIL, "database has no revision", "uv run stk db upgrade"
|
|
120
|
+
)
|
|
121
|
+
unknown = sorted(rev for rev in current if not _defined(script, rev))
|
|
122
|
+
if unknown:
|
|
123
|
+
return _finding(
|
|
124
|
+
"migrations",
|
|
125
|
+
FAIL,
|
|
126
|
+
f"database is stamped at {', '.join(unknown)}, which no migration defines",
|
|
127
|
+
"uv run stk db stamp heads (after checking the schema matches)",
|
|
128
|
+
)
|
|
129
|
+
if missing := sorted(heads - _applied(script, current)):
|
|
130
|
+
return _finding(
|
|
131
|
+
"migrations",
|
|
132
|
+
WARN,
|
|
133
|
+
f"database is behind: {', '.join(missing)} not applied",
|
|
134
|
+
"uv run stk db upgrade",
|
|
135
|
+
)
|
|
136
|
+
return _finding("migrations", OK, f"at heads ({shown})")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_admin():
|
|
140
|
+
from stk.user.models import Role, User
|
|
141
|
+
|
|
142
|
+
async def _count():
|
|
143
|
+
async with ext.async_session_factory() as session:
|
|
144
|
+
statement = (
|
|
145
|
+
select(func.count())
|
|
146
|
+
.select_from(User)
|
|
147
|
+
.where(User.roles.any(Role.name == "admin"))
|
|
148
|
+
)
|
|
149
|
+
return (await session.execute(statement)).scalar_one()
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
admins = run_async(_count())
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
return _finding("admin user", FAIL, str(exc)[:80], "uv run stk db upgrade")
|
|
155
|
+
if admins:
|
|
156
|
+
return _finding("admin user", OK, f"{admins} admin(s)")
|
|
157
|
+
return _finding("admin user", WARN, "no admin user", "uv run stk install")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _check_assets():
|
|
161
|
+
versions = CORE_STATIC / "VERSIONS.txt"
|
|
162
|
+
if not versions.exists():
|
|
163
|
+
return _finding("assets", WARN, "VERSIONS.txt missing", "./vendor.sh")
|
|
164
|
+
expected = [
|
|
165
|
+
CORE_STATIC / name
|
|
166
|
+
for name in (
|
|
167
|
+
"js/vue.min.js",
|
|
168
|
+
"js/vuetify.min.js",
|
|
169
|
+
"css/vuetify.min.css",
|
|
170
|
+
"icons/tabler-icons.min.css",
|
|
171
|
+
"icons/fonts/tabler-icons.woff2",
|
|
172
|
+
)
|
|
173
|
+
]
|
|
174
|
+
missing = [path.name for path in expected if not path.exists()]
|
|
175
|
+
if missing:
|
|
176
|
+
return _finding("assets", FAIL, f"missing: {', '.join(missing)}", "./vendor.sh")
|
|
177
|
+
pinned = " ".join(versions.read_text().split("\n")[0:2]).strip()
|
|
178
|
+
return _finding("assets", OK, pinned)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _check_agent_login(config):
|
|
182
|
+
enabled = config.get("STK_ENABLE_AGENT_LOGIN", False)
|
|
183
|
+
env = config.get("STK_ENV", "production")
|
|
184
|
+
if enabled and env != "development":
|
|
185
|
+
return _finding(
|
|
186
|
+
"agent login",
|
|
187
|
+
FAIL,
|
|
188
|
+
f"test-only login enabled with STK_ENV={env}",
|
|
189
|
+
"unset STK_ENABLE_AGENT_LOGIN, or set STK_ENV=development",
|
|
190
|
+
)
|
|
191
|
+
return _finding(
|
|
192
|
+
"agent login", OK, "enabled in development only" if enabled else "disabled"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _check_browser():
|
|
197
|
+
try:
|
|
198
|
+
from playwright.sync_api import sync_playwright
|
|
199
|
+
except ImportError:
|
|
200
|
+
return _finding(
|
|
201
|
+
"browser", WARN, "playwright not installed", "uv sync --extra dev"
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
with sync_playwright() as playwright:
|
|
205
|
+
path = Path(playwright.chromium.executable_path)
|
|
206
|
+
except Exception:
|
|
207
|
+
return _finding(
|
|
208
|
+
"browser",
|
|
209
|
+
WARN,
|
|
210
|
+
"chromium not installed",
|
|
211
|
+
"uv run playwright install chromium",
|
|
212
|
+
)
|
|
213
|
+
if not path.exists():
|
|
214
|
+
return _finding(
|
|
215
|
+
"browser",
|
|
216
|
+
WARN,
|
|
217
|
+
"chromium not installed",
|
|
218
|
+
"uv run playwright install chromium",
|
|
219
|
+
)
|
|
220
|
+
return _finding("browser", OK, "chromium ready for stk smoke")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _check_repl():
|
|
224
|
+
try:
|
|
225
|
+
import ptpython # noqa: F401
|
|
226
|
+
except ImportError:
|
|
227
|
+
return _finding(
|
|
228
|
+
"shell",
|
|
229
|
+
WARN,
|
|
230
|
+
"ptpython missing, stk shell falls back to the plain console",
|
|
231
|
+
"uv sync --extra dev",
|
|
232
|
+
)
|
|
233
|
+
return _finding("shell", OK, "ptpython: highlighting and completion")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def build_doctor_report():
|
|
237
|
+
"""Return every finding, plus the worst status seen."""
|
|
238
|
+
findings = [_check_env_file(project_root())]
|
|
239
|
+
try:
|
|
240
|
+
app = cli_app()
|
|
241
|
+
except Exception as exc: # an app that cannot load is the diagnosis
|
|
242
|
+
findings.append(
|
|
243
|
+
_finding("app", FAIL, str(exc)[:120], "read the error, then rerun")
|
|
244
|
+
)
|
|
245
|
+
app = None
|
|
246
|
+
if app is not None:
|
|
247
|
+
findings += [
|
|
248
|
+
_finding("app", OK, app_spec()),
|
|
249
|
+
_check_secrets(app.config),
|
|
250
|
+
_check_database(app.config),
|
|
251
|
+
_check_migrations(app),
|
|
252
|
+
_check_admin(),
|
|
253
|
+
_check_agent_login(app.config),
|
|
254
|
+
]
|
|
255
|
+
findings += [_check_assets(), _check_browser(), _check_repl()]
|
|
256
|
+
if any(item["status"] == FAIL for item in findings):
|
|
257
|
+
status = FAIL
|
|
258
|
+
elif any(item["status"] == WARN for item in findings):
|
|
259
|
+
status = WARN
|
|
260
|
+
else:
|
|
261
|
+
status = OK
|
|
262
|
+
return {"status": status, "findings": findings}
|
stk/cli/main.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The `stk` command: one entry point for the whole framework.
|
|
2
|
+
|
|
3
|
+
Wraps Quart's own CLI group. Every command in `stk/cli/__init__.py` lives on
|
|
4
|
+
this group, so commands that need no app (`stk init`) run anywhere. The app is
|
|
5
|
+
found from `[tool.stk] app` in the nearest pyproject.toml, or from STK_APP, and
|
|
6
|
+
commands it registers (`app.cli.add_command`) appear here too.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from quart.cli import QuartGroup
|
|
14
|
+
|
|
15
|
+
import stk.cli as cli
|
|
16
|
+
from stk.cli.base import load_app
|
|
17
|
+
|
|
18
|
+
SECTIONS = [
|
|
19
|
+
("Develop", ["init", "run", "shell", "new", "routes"]),
|
|
20
|
+
("Database", ["db", "create-db"]),
|
|
21
|
+
("Verify", ["doctor", "verify", "smoke", "inspect", "report"]),
|
|
22
|
+
(
|
|
23
|
+
"Accounts",
|
|
24
|
+
[
|
|
25
|
+
"install",
|
|
26
|
+
"create",
|
|
27
|
+
"add-role",
|
|
28
|
+
"reset",
|
|
29
|
+
"cleanup-sessions",
|
|
30
|
+
"browser-token",
|
|
31
|
+
"protect-mfa",
|
|
32
|
+
],
|
|
33
|
+
),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StkGroup(QuartGroup):
|
|
38
|
+
"""Quart's CLI group, listing commands by what they are for."""
|
|
39
|
+
|
|
40
|
+
def format_commands(self, ctx, formatter):
|
|
41
|
+
commands = {}
|
|
42
|
+
for name in self.list_commands(ctx):
|
|
43
|
+
command = self.get_command(ctx, name)
|
|
44
|
+
if command is not None and not command.hidden:
|
|
45
|
+
commands[name] = command
|
|
46
|
+
|
|
47
|
+
for title, names in SECTIONS:
|
|
48
|
+
rows = [
|
|
49
|
+
(name, commands.pop(name).get_short_help_str(limit=60))
|
|
50
|
+
for name in names
|
|
51
|
+
if name in commands
|
|
52
|
+
]
|
|
53
|
+
if rows:
|
|
54
|
+
with formatter.section(title):
|
|
55
|
+
formatter.write_dl(rows)
|
|
56
|
+
|
|
57
|
+
if commands:
|
|
58
|
+
with formatter.section("Other"):
|
|
59
|
+
formatter.write_dl(
|
|
60
|
+
[
|
|
61
|
+
(name, command.get_short_help_str(limit=60))
|
|
62
|
+
for name, command in sorted(commands.items())
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _create_app(**_kwargs):
|
|
68
|
+
return load_app()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _print_version(ctx, _param, value):
|
|
72
|
+
if not value or ctx.resilient_parsing:
|
|
73
|
+
return
|
|
74
|
+
# Apps rename the distribution, so report the framework package's own version.
|
|
75
|
+
import stk
|
|
76
|
+
|
|
77
|
+
click.echo(f"stk {stk.__version__} (quart {version('quart')})")
|
|
78
|
+
ctx.exit()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
main = StkGroup(
|
|
82
|
+
name="stk",
|
|
83
|
+
create_app=_create_app,
|
|
84
|
+
add_version_option=False,
|
|
85
|
+
help="stk: async Quart framework with a Vue frontend and no build step.",
|
|
86
|
+
params=[
|
|
87
|
+
click.Option(
|
|
88
|
+
["-v", "--version"],
|
|
89
|
+
is_flag=True,
|
|
90
|
+
expose_value=False,
|
|
91
|
+
is_eager=True,
|
|
92
|
+
callback=_print_version,
|
|
93
|
+
help="Show the stk version and exit.",
|
|
94
|
+
)
|
|
95
|
+
],
|
|
96
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
for _name, _command in inspect.getmembers(cli):
|
|
100
|
+
if isinstance(_command, click.Command):
|
|
101
|
+
main.add_command(_command)
|
stk/cli/reports.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Machine-readable reports behind `stk inspect`, `stk verify`, and `stk report`."""
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import inspect as pyinspect
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from stk.migrations import import_model_modules
|
|
10
|
+
|
|
11
|
+
VERIFY_COMMANDS = [
|
|
12
|
+
("ruff", ["ruff", "check", "."]),
|
|
13
|
+
("checks", [sys.executable, "-m", "stk.checks"]),
|
|
14
|
+
("migration-drift", [sys.executable, "-m", "stk", "db", "check"]),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# A failing check that does not say how to fix it makes the reader do the work
|
|
18
|
+
# twice. Every check names its remedy, in the report and in the JSON an agent reads.
|
|
19
|
+
REMEDIES = {
|
|
20
|
+
"ruff": "uv run ruff check --fix . && uv run ruff format .",
|
|
21
|
+
"checks": "read the failed assertion above; each check names what it expected",
|
|
22
|
+
"migration-drift": 'uv run stk db revision -m "describe change" && uv run stk db upgrade',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _command_runner(command):
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
command,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
check=False,
|
|
33
|
+
)
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
if command[0] == "ruff":
|
|
36
|
+
return None, "", "ruff not installed"
|
|
37
|
+
raise
|
|
38
|
+
return result.returncode, result.stdout, result.stderr
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _route_source(view_func):
|
|
42
|
+
if view_func is None:
|
|
43
|
+
return None
|
|
44
|
+
source_file = pyinspect.getsourcefile(view_func)
|
|
45
|
+
if source_file is None:
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
_, line = pyinspect.getsourcelines(view_func)
|
|
49
|
+
except OSError:
|
|
50
|
+
line = None
|
|
51
|
+
source = {"file": str(Path(source_file).resolve())}
|
|
52
|
+
if line is not None:
|
|
53
|
+
source["line"] = line
|
|
54
|
+
return source
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _guards(func):
|
|
58
|
+
"""Return the quart-security guards wrapping `func`, outermost first.
|
|
59
|
+
|
|
60
|
+
ponytail: identifies the guards by the decorator's closure qualname because
|
|
61
|
+
quart-security exposes no marker. `checks.py` asserts the result against real
|
|
62
|
+
requests, so a rename upstream fails the gate instead of silently reporting
|
|
63
|
+
every route as open.
|
|
64
|
+
"""
|
|
65
|
+
guards = []
|
|
66
|
+
while func is not None:
|
|
67
|
+
qualname = getattr(getattr(func, "__code__", None), "co_qualname", "")
|
|
68
|
+
for guard in ("auth_required", "roles_required"):
|
|
69
|
+
if qualname.startswith(f"{guard}."):
|
|
70
|
+
guards.append(guard)
|
|
71
|
+
func = getattr(func, "__wrapped__", None)
|
|
72
|
+
return guards
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _route_auth(app, rule):
|
|
76
|
+
"""Report whether a route is guarded, and where the guard is declared."""
|
|
77
|
+
if rule.rule.startswith("/_test/"):
|
|
78
|
+
return {"required": False, "source": "test-only", "scheme": "agent-token"}
|
|
79
|
+
|
|
80
|
+
blueprint = rule.endpoint.rsplit(".", 1)[0] if "." in rule.endpoint else None
|
|
81
|
+
if _guards(app.view_functions.get(rule.endpoint)):
|
|
82
|
+
return {"required": True, "source": "route", "scheme": "session"}
|
|
83
|
+
|
|
84
|
+
for func in app.before_request_funcs.get(blueprint, []):
|
|
85
|
+
if _guards(func):
|
|
86
|
+
return {"required": True, "source": "blueprint", "scheme": "session"}
|
|
87
|
+
|
|
88
|
+
return {"required": False, "source": "unguarded", "scheme": "public"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_routes_report(app):
|
|
92
|
+
"""Return machine-readable route facts for agents and tooling."""
|
|
93
|
+
routes = []
|
|
94
|
+
for rule in sorted(app.url_map.iter_rules(), key=lambda item: item.rule):
|
|
95
|
+
methods = sorted((rule.methods or set()) - {"HEAD", "OPTIONS"})
|
|
96
|
+
view_func = app.view_functions.get(rule.endpoint)
|
|
97
|
+
blueprint = rule.endpoint.rsplit(".", 1)[0] if "." in rule.endpoint else None
|
|
98
|
+
routes.append(
|
|
99
|
+
{
|
|
100
|
+
"rule": rule.rule,
|
|
101
|
+
"endpoint": rule.endpoint,
|
|
102
|
+
"blueprint": blueprint,
|
|
103
|
+
"methods": methods,
|
|
104
|
+
"arguments": sorted(rule.arguments),
|
|
105
|
+
"auth": _route_auth(app, rule),
|
|
106
|
+
"source": _route_source(view_func),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
return routes
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_models_report():
|
|
113
|
+
"""Return model metadata in a compact, agent-readable shape."""
|
|
114
|
+
from stk.extensions import Base
|
|
115
|
+
|
|
116
|
+
import_model_modules()
|
|
117
|
+
models = {}
|
|
118
|
+
for table_name, table in sorted(Base.metadata.tables.items()):
|
|
119
|
+
models[table_name] = {
|
|
120
|
+
"columns": [
|
|
121
|
+
{
|
|
122
|
+
"name": column.name,
|
|
123
|
+
"type": str(column.type),
|
|
124
|
+
"nullable": column.nullable,
|
|
125
|
+
"primary_key": column.primary_key,
|
|
126
|
+
}
|
|
127
|
+
for column in table.columns
|
|
128
|
+
],
|
|
129
|
+
"indexes": sorted(index.name for index in table.indexes if index.name),
|
|
130
|
+
}
|
|
131
|
+
return models
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_context_report(app):
|
|
135
|
+
"""Return the project context contract used by agents and tooling."""
|
|
136
|
+
return {
|
|
137
|
+
"routes": build_routes_report(app),
|
|
138
|
+
"models": build_models_report(),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_commands_report(root):
|
|
143
|
+
"""Return every stk command, walked from the live click tree under `root`."""
|
|
144
|
+
import click
|
|
145
|
+
|
|
146
|
+
from stk.cli.main import SECTIONS
|
|
147
|
+
|
|
148
|
+
section_of = {name: title for title, names in SECTIONS for name in names}
|
|
149
|
+
commands = []
|
|
150
|
+
|
|
151
|
+
def walk(ctx, path):
|
|
152
|
+
command = ctx.command
|
|
153
|
+
if isinstance(command, click.Group):
|
|
154
|
+
for name in command.list_commands(ctx):
|
|
155
|
+
child = command.get_command(ctx, name)
|
|
156
|
+
if child is not None and not child.hidden:
|
|
157
|
+
walk(
|
|
158
|
+
click.Context(child, info_name=name, parent=ctx), [*path, name]
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
help_text = pyinspect.cleandoc(command.help or "").split("\n\n")[0]
|
|
162
|
+
commands.append(
|
|
163
|
+
{
|
|
164
|
+
"command": " ".join(["stk", *path]),
|
|
165
|
+
"section": section_of.get(path[0], "Other"),
|
|
166
|
+
"usage": " ".join(["stk", *path, *command.collect_usage_pieces(ctx)]),
|
|
167
|
+
"help": " ".join(help_text.split()),
|
|
168
|
+
"options": [
|
|
169
|
+
{
|
|
170
|
+
"flags": ", ".join(param.opts + param.secondary_opts),
|
|
171
|
+
"help": " ".join((param.help or "").split()),
|
|
172
|
+
}
|
|
173
|
+
for param in command.params
|
|
174
|
+
if isinstance(param, click.Option)
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
walk(root, [])
|
|
180
|
+
order = [title for title, _ in SECTIONS] + ["Other"]
|
|
181
|
+
return sorted(commands, key=lambda c: (order.index(c["section"]), c["command"]))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def render_commands_markdown(commands):
|
|
185
|
+
"""Render the commands report as the docs CLI reference page."""
|
|
186
|
+
lines = [
|
|
187
|
+
"---",
|
|
188
|
+
'title: "CLI Reference"',
|
|
189
|
+
'description: "Every stk command and option, generated from the code"',
|
|
190
|
+
"---",
|
|
191
|
+
"",
|
|
192
|
+
"Generated from the live command tree by `uv run stk inspect commands --markdown`.",
|
|
193
|
+
"Do not edit it by hand: `stk verify` fails when this page and the code disagree.",
|
|
194
|
+
"Agents can read the same data as JSON with `uv run stk inspect commands --json`.",
|
|
195
|
+
]
|
|
196
|
+
section = None
|
|
197
|
+
for command in commands:
|
|
198
|
+
if command["section"] != section:
|
|
199
|
+
section = command["section"]
|
|
200
|
+
lines += ["", f"## {section}"]
|
|
201
|
+
lines += ["", f"### `{command['command']}`", "", f"`{command['usage']}`"]
|
|
202
|
+
if command["help"]:
|
|
203
|
+
lines += ["", command["help"]]
|
|
204
|
+
if command["options"]:
|
|
205
|
+
lines += ["", "| Option | Description |", "|---|---|"]
|
|
206
|
+
for option in command["options"]:
|
|
207
|
+
description = option["help"].replace("|", r"\|")
|
|
208
|
+
lines.append(f"| `{option['flags']}` | {description} |")
|
|
209
|
+
return "\n".join(lines) + "\n"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_verify_report(commands=None, runner=_command_runner):
|
|
213
|
+
"""Run verification commands and return a compact report."""
|
|
214
|
+
checks = []
|
|
215
|
+
for name, command_args in commands or VERIFY_COMMANDS:
|
|
216
|
+
returncode, stdout, stderr = runner(command_args)
|
|
217
|
+
checks.append(
|
|
218
|
+
{
|
|
219
|
+
"name": name,
|
|
220
|
+
"command": command_args,
|
|
221
|
+
"returncode": returncode,
|
|
222
|
+
"status": "skipped"
|
|
223
|
+
if returncode is None
|
|
224
|
+
else "passed"
|
|
225
|
+
if returncode == 0
|
|
226
|
+
else "failed",
|
|
227
|
+
"stdout": stdout.strip(),
|
|
228
|
+
"stderr": stderr.strip(),
|
|
229
|
+
"remedy": REMEDIES.get(name),
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
status = (
|
|
233
|
+
"passed"
|
|
234
|
+
if all(check["returncode"] in (0, None) for check in checks)
|
|
235
|
+
else "failed"
|
|
236
|
+
)
|
|
237
|
+
return {"status": status, "checks": checks}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def build_project_report_html(routes, verify_report):
|
|
241
|
+
"""Render a static project review artifact."""
|
|
242
|
+
rows = "\n".join(
|
|
243
|
+
"<tr>"
|
|
244
|
+
f"<td>{html.escape(route['rule'])}</td>"
|
|
245
|
+
f"<td>{html.escape(', '.join(route['methods']))}</td>"
|
|
246
|
+
f"<td>{html.escape(str(route['blueprint']))}</td>"
|
|
247
|
+
f"<td>{html.escape(route['auth']['scheme'])}</td>"
|
|
248
|
+
f"<td>{html.escape(route['auth']['source'])}</td>"
|
|
249
|
+
"</tr>"
|
|
250
|
+
for route in routes
|
|
251
|
+
)
|
|
252
|
+
check_rows = "\n".join(
|
|
253
|
+
"<tr>"
|
|
254
|
+
f"<td>{html.escape(check['name'])}</td>"
|
|
255
|
+
f"<td>{html.escape(check['status'])}</td>"
|
|
256
|
+
f"<td>{html.escape(' '.join(check['command']))}</td>"
|
|
257
|
+
"</tr>"
|
|
258
|
+
for check in verify_report["checks"]
|
|
259
|
+
)
|
|
260
|
+
status = html.escape(verify_report["status"])
|
|
261
|
+
return f"""<!doctype html>
|
|
262
|
+
<html lang="en">
|
|
263
|
+
<head>
|
|
264
|
+
<meta charset="utf-8">
|
|
265
|
+
<title>STK Project Report</title>
|
|
266
|
+
<style>
|
|
267
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; color: #1f2933; }}
|
|
268
|
+
h1 {{ font-size: 28px; margin-bottom: 8px; }}
|
|
269
|
+
h2 {{ font-size: 18px; margin-top: 28px; }}
|
|
270
|
+
table {{ border-collapse: collapse; width: 100%; }}
|
|
271
|
+
th, td {{ border-bottom: 1px solid #d8dee4; padding: 8px; text-align: left; }}
|
|
272
|
+
th {{ background: #f6f8fa; }}
|
|
273
|
+
.status {{ display: inline-block; padding: 4px 8px; border: 1px solid #d8dee4; }}
|
|
274
|
+
</style>
|
|
275
|
+
</head>
|
|
276
|
+
<body>
|
|
277
|
+
<h1>STK Project Report</h1>
|
|
278
|
+
<p>Generated route and verification artifact for reviewing project boundaries.</p>
|
|
279
|
+
<p>Verification status: <span class="status">{status}</span></p>
|
|
280
|
+
<h2>Routes</h2>
|
|
281
|
+
<table>
|
|
282
|
+
<thead>
|
|
283
|
+
<tr><th>Route</th><th>Methods</th><th>Blueprint</th><th>Auth</th><th>Source</th></tr>
|
|
284
|
+
</thead>
|
|
285
|
+
<tbody>{rows}</tbody>
|
|
286
|
+
</table>
|
|
287
|
+
<h2>Verification</h2>
|
|
288
|
+
<table>
|
|
289
|
+
<thead>
|
|
290
|
+
<tr><th>Check</th><th>Status</th><th>Command</th></tr>
|
|
291
|
+
</thead>
|
|
292
|
+
<tbody>{check_rows}</tbody>
|
|
293
|
+
</table>
|
|
294
|
+
</body>
|
|
295
|
+
</html>
|
|
296
|
+
"""
|