stkit 17.0.2__tar.gz → 17.1.0__tar.gz
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.
- {stkit-17.0.2 → stkit-17.1.0}/CHANGELOG.md +26 -0
- {stkit-17.0.2 → stkit-17.1.0}/PKG-INFO +1 -1
- {stkit-17.0.2 → stkit-17.1.0}/pyproject.toml +1 -1
- {stkit-17.0.2 → stkit-17.1.0}/stk/__init__.py +1 -1
- {stkit-17.0.2 → stkit-17.1.0}/stk/app.py +32 -1
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/database.py +55 -23
- {stkit-17.0.2 → stkit-17.1.0}/stk/migrations/__init__.py +22 -1
- {stkit-17.0.2 → stkit-17.1.0}/stk/migrations/env.py +3 -1
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/layout.html +1 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_composition.py +26 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_migration_drift.py +30 -0
- stkit-17.1.0/tests/test_revision_imports.py +108 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_scaffolder.py +2 -1
- {stkit-17.0.2 → stkit-17.1.0}/.gitignore +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/LICENSE +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/README.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/SECURITY.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/.env-sample +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/.gitignore +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/README.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/app.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/migrations/364232ae77d5_add_note.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/note/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/note/models.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/note/templates/note/index.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/note/views.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/settings.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/templates/layout.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/pyproject.toml +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/tests/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/tests/test_app.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/uv.lock +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/__main__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/agent_login.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/checks.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/agent.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/base.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/develop.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/doctor.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/main.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/reports.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/smoke.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/users.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/cli/watch.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/extensions.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/migrations/script.py.mako +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/migrations/versions/stk_0001_initial_schema.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/portal/views.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/public/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/public/models.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/public/views.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/CLAUDE.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/Dockerfile +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/README.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/__app__/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/__app__/app.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/__app__/settings.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-api/SKILL.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/docker-compose.yml +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/dockerignore +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/env-sample +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/github/workflows/checks.yml +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/gitignore +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/nginx/nginx.conf +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/nginx/stk.conf +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/postgres/init-db.sh +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/pyproject.toml +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/tests/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/tests/test_app.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/generator.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/templates.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/security.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/settings.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/shell.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/VERSIONS.txt +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/css/app.css +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/css/layout.css +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/css/vuetify.min.css +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/icons/fonts/tabler-icons.woff2 +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/icons/tabler-icons.min.css +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/img/auth-bg.webp +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/img/favicon.ico +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/img/stk.svg +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/axios.min.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/NotificationDropdown.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/ThemeSwitcher.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/TransitionExpand.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/VerticalNavGroup.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/VerticalNavLink.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/VerticalNavSectionTitle.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/index.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/config.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/vue.min.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/vuetify.min.js +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/static/robots.txt +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/tasks/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/401.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/404.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/500.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/_brand.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/auth_layout.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/cms/activities.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/cms/roles.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/cms/users.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/dashboard.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/index.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/login-layout.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/_macros.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/_messages.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/change_password.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/confirm_email.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/change_notice.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/change_notice.txt +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/confirmation_instructions.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/confirmation_instructions.txt +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/login_instructions.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/login_instructions.txt +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/reset_instructions.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/reset_instructions.txt +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/reset_notice.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/reset_notice.txt +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/welcome.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/welcome.txt +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/forgot_password.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/login_user.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/mf_recovery.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/mf_recovery_codes.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/register_user.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/reset_password.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/send_confirmation.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/send_login.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/two_factor_select.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/two_factor_setup.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/two_factor_verify_code.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/wan_register.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/wan_signin.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/wan_verify.html +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/user/__init__.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/user/forms.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/user/models.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/user/views.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/utils/email.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/utils/ratelimit.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/stk/websocket.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_agent_operability.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_app_index.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_branding.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_builder_ux.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_cli.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_csrf_exempt.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_error_handler.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_production_security.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_security_compatibility.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_shell.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_smoke_pages.py +0 -0
- {stkit-17.0.2 → stkit-17.1.0}/tests/test_static_versioning.py +0 -0
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v17.1.0 (2026-10-25)
|
|
4
|
+
|
|
5
|
+
No migration required.
|
|
6
|
+
|
|
7
|
+
- `app_navigation()` returns the app's `app.navigation` entries alone, without stk's Dashboard, Account and Administration sections, for a layout that draws its own sidebar (Frontend, Navigation).
|
|
8
|
+
- The layout ships an import map, `{{ import_map() }}`, that maps every `.js` and `.mjs` file in the app's, extensions' and stk's static folders to its versioned URL, app first. A module imported by another module is fresh after a deploy too. A layout that does not extend `stk/layout.html` calls it in its `<head>` (Frontend).
|
|
9
|
+
- `build_alembic_config("myapp", url)` takes the app's import name with a URL, so a test can migrate a throwaway database before it builds its app. Passing the app still works (Models, Migrations).
|
|
10
|
+
- `stk db revision` imports the module of each column type Alembic writes by its path (pgvector's `HALFVEC` and `BIT`, an app's `UserDefinedType`), then sorts the revision's imports and formats it with the app's ruff config. Before, such revisions failed with a `NameError`.
|
|
11
|
+
- `stk db revision` warns about each column-level `CheckConstraint` in the columns it adds: Alembic autogenerate leaves them out. Declare checks in `__table_args__`, or add them to the revision by hand (Models, Migrations).
|
|
12
|
+
- Configuration documents app paths in `settings.py` (`Path(__file__).resolve().parent.parent`) in place of the removed `Config.PROJECT_ROOT`, and `app.root_path` and `app.instance_path` at runtime.
|
|
13
|
+
|
|
14
|
+
### Upgrade considerations
|
|
15
|
+
|
|
16
|
+
Nothing breaks. Apps can delete what they wrote around these gaps:
|
|
17
|
+
|
|
18
|
+
- A Jinja global that returns `app.navigation` (Sealbook's `rail_navigation` in `sealbook/shell/__init__.py`): call `app_navigation()` in the layout instead.
|
|
19
|
+
- An app import map (Sealbook's `import_map()` and `MODULE_DIRS` in `sealbook/assets.py`): stk's `import_map()` global has the same name, so a copied layout that calls `{{ import_map() }}` keeps working. stk's map also lists minified `.js` files; they are harmless there.
|
|
20
|
+
- Tests that import the module-level app only to call `build_alembic_config(app, url)`: pass `"myapp"` instead.
|
|
21
|
+
- Nothing in existing revisions: hand-written imports there stay correct. New revisions import their types themselves.
|
|
22
|
+
|
|
23
|
+
Paste-ready prompt for a coding agent:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
Upgrade this app to stkit 17.1.0 (bump the pin, `uv lock`). Then delete the workarounds 17.1.0 makes unnecessary: (1) any Jinja global returning app.navigation; call app_navigation() in the layout instead. (2) any app-built import map of versioned static modules; stk's import_map() global replaces it, so keep `{{ import_map() }}` in a copied layout and delete the app's function and its registration, plus test assertions that minified files are absent from the map. (3) in tests, `build_alembic_config(app, url, ...)` where the app was imported only for that: pass the app's import name, `build_alembic_config("<package>", url, ...)`, and drop the import. Leave existing migration revisions alone, including their hand-written imports and checks. Done when the tests, `uv run stk verify` and `uv run stk smoke` pass.
|
|
27
|
+
```
|
|
28
|
+
|
|
3
29
|
## v17.0.2 (2026-10-25)
|
|
4
30
|
|
|
5
31
|
No migration required.
|
|
@@ -4,11 +4,20 @@ import hashlib
|
|
|
4
4
|
import importlib
|
|
5
5
|
import importlib.util
|
|
6
6
|
import inspect
|
|
7
|
+
import json
|
|
7
8
|
import os
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from jinja2 import ChoiceLoader, FileSystemLoader, PrefixLoader
|
|
11
|
-
from
|
|
12
|
+
from markupsafe import Markup
|
|
13
|
+
from quart import (
|
|
14
|
+
Quart,
|
|
15
|
+
g,
|
|
16
|
+
render_template,
|
|
17
|
+
request,
|
|
18
|
+
send_from_directory,
|
|
19
|
+
url_for,
|
|
20
|
+
)
|
|
12
21
|
from quart_security import Security, SQLAlchemyUserDatastore
|
|
13
22
|
from quart_security.views import _ensure_csrf_token
|
|
14
23
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
@@ -232,6 +241,26 @@ def register_static_versioning(app):
|
|
|
232
241
|
)
|
|
233
242
|
return response
|
|
234
243
|
|
|
244
|
+
def import_map():
|
|
245
|
+
"""Map each module's plain URL to its versioned one, so a module imported by
|
|
246
|
+
another module (`import "./x.js"`) is fresh after a deploy too."""
|
|
247
|
+
names = sorted(
|
|
248
|
+
{
|
|
249
|
+
path.relative_to(folder).as_posix()
|
|
250
|
+
for folder in app.static_folders
|
|
251
|
+
for path in folder.rglob("*")
|
|
252
|
+
if path.suffix in (".js", ".mjs")
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
imports = {
|
|
256
|
+
f"{app.static_url_path}/{name}": url_for("static", filename=name)
|
|
257
|
+
for name in names
|
|
258
|
+
}
|
|
259
|
+
body = json.dumps({"imports": imports}).replace("</", "<\\/")
|
|
260
|
+
return Markup(f'<script type="importmap">{body}</script>')
|
|
261
|
+
|
|
262
|
+
app.jinja_env.globals["import_map"] = import_map
|
|
263
|
+
|
|
235
264
|
|
|
236
265
|
CORE_NAVIGATION_START = [
|
|
237
266
|
{"heading": "Main"},
|
|
@@ -267,6 +296,8 @@ def register_navigation(app):
|
|
|
267
296
|
*app.navigation,
|
|
268
297
|
*CORE_NAVIGATION_END,
|
|
269
298
|
]
|
|
299
|
+
# The app's entries alone, for a layout that draws its own sidebar.
|
|
300
|
+
app.jinja_env.globals["app_navigation"] = lambda: app.navigation
|
|
270
301
|
|
|
271
302
|
|
|
272
303
|
def register_errorhandlers(app):
|
|
@@ -79,6 +79,44 @@ def build_migration_drift_report(app=None, database_url=None):
|
|
|
79
79
|
return [_describe_diff(diff) for diff in _flatten(diffs)]
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
def _column_checks(op):
|
|
83
|
+
"""`table.column` for each column an autogenerated op adds with its own check."""
|
|
84
|
+
from sqlalchemy import CheckConstraint, Column
|
|
85
|
+
|
|
86
|
+
for child in getattr(op, "ops", ()):
|
|
87
|
+
yield from _column_checks(child)
|
|
88
|
+
columns = [*getattr(op, "columns", ()), getattr(op, "column", None)]
|
|
89
|
+
for column in columns:
|
|
90
|
+
if isinstance(column, Column) and any(
|
|
91
|
+
isinstance(c, CheckConstraint) for c in column.constraints
|
|
92
|
+
):
|
|
93
|
+
yield f"{column.table.name}.{column.name}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _warn_column_checks(context, revision, directives):
|
|
97
|
+
# Alembic renders table-level checks only; a column's own check is left out.
|
|
98
|
+
for name in _column_checks(directives[0].upgrade_ops):
|
|
99
|
+
console.print(
|
|
100
|
+
f"[yellow]{name}: autogenerate leaves out a column-level CheckConstraint."
|
|
101
|
+
" Move it to __table_args__, or add it to this revision by hand.[/]"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _tidy_revision(path):
|
|
106
|
+
"""Sort the revision's imports and format it with the app's own ruff config."""
|
|
107
|
+
import importlib.util
|
|
108
|
+
import subprocess
|
|
109
|
+
import sys
|
|
110
|
+
|
|
111
|
+
if importlib.util.find_spec("ruff") is None:
|
|
112
|
+
console.print("[yellow]ruff is not installed: revision imports are unsorted[/]")
|
|
113
|
+
return
|
|
114
|
+
for args in (["check", "--fix", "--select", "I"], ["format"]):
|
|
115
|
+
subprocess.run(
|
|
116
|
+
[sys.executable, "-m", "ruff", *args, "--quiet", path], check=True
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
82
120
|
def generate_revision(app, message, *, autogenerate=True, head=None, quiet=False):
|
|
83
121
|
"""Write a revision in the app's lineage, or in `head`'s when given.
|
|
84
122
|
|
|
@@ -89,35 +127,29 @@ def generate_revision(app, message, *, autogenerate=True, head=None, quiet=False
|
|
|
89
127
|
|
|
90
128
|
config = build_alembic_config(app, quiet=quiet)
|
|
91
129
|
if head is not None:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
130
|
+
options = {"head": head}
|
|
131
|
+
else:
|
|
132
|
+
script = ScriptDirectory.from_config(config)
|
|
133
|
+
label = app_label(app)
|
|
134
|
+
started = any(label in rev.branch_labels for rev in script.walk_revisions())
|
|
135
|
+
options = {"depends_on": script.get_revision(f"{CORE_LABEL}@head").revision}
|
|
136
|
+
if started:
|
|
137
|
+
options["head"] = f"{label}@head"
|
|
138
|
+
else:
|
|
139
|
+
options.update(
|
|
140
|
+
head="base", branch_label=label, version_path=str(app_versions(app))
|
|
141
|
+
)
|
|
142
|
+
app_versions(app).mkdir(exist_ok=True)
|
|
143
|
+
revision = run_alembic(
|
|
114
144
|
command.revision,
|
|
115
145
|
config,
|
|
116
146
|
message=message,
|
|
117
147
|
autogenerate=autogenerate,
|
|
118
|
-
|
|
148
|
+
process_revision_directives=_warn_column_checks,
|
|
119
149
|
**options,
|
|
120
150
|
)
|
|
151
|
+
_tidy_revision(revision.path)
|
|
152
|
+
return revision
|
|
121
153
|
|
|
122
154
|
|
|
123
155
|
@click.command()
|
|
@@ -7,6 +7,7 @@ label; an app's carry its package name and live in `<package>/migrations/`.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import importlib
|
|
10
|
+
import importlib.util
|
|
10
11
|
import os
|
|
11
12
|
import pkgutil
|
|
12
13
|
import tempfile
|
|
@@ -55,6 +56,17 @@ def include_object(obj, name, type_, reflected, compare_to):
|
|
|
55
56
|
return not (type_ == "table" and name in EXTENSION_TABLES)
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
def render_item(type_, obj, autogen_context):
|
|
60
|
+
"""Import the module of a type Alembic renders by its path (pgvector's, the app's).
|
|
61
|
+
|
|
62
|
+
Alembic writes such a type as `pgvector.sqlalchemy.halfvec.HALFVEC(dim=3)` and
|
|
63
|
+
imports only SQLAlchemy dialects, so the revision would not run.
|
|
64
|
+
"""
|
|
65
|
+
if type_ == "type" and not type(obj).__module__.startswith("sqlalchemy."):
|
|
66
|
+
autogen_context.imports.add(f"import {type(obj).__module__}")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
58
70
|
def sync_url(database_url: str) -> str:
|
|
59
71
|
"""Convert an async driver URL to the sync driver Alembic uses."""
|
|
60
72
|
url = make_url(database_url)
|
|
@@ -71,11 +83,20 @@ def app_label(app) -> str:
|
|
|
71
83
|
|
|
72
84
|
|
|
73
85
|
def app_versions(app) -> Path:
|
|
86
|
+
"""The app's migrations directory; `app` is the app or its import name."""
|
|
87
|
+
if isinstance(app, str):
|
|
88
|
+
return Path(importlib.util.find_spec(app).origin).parent / "migrations"
|
|
74
89
|
return Path(app.root_path) / "migrations"
|
|
75
90
|
|
|
76
91
|
|
|
77
92
|
def build_alembic_config(app=None, url=None, *, quiet=False) -> AlembicConfig:
|
|
78
|
-
"""Build an Alembic config for stk's lineage plus the app's, if given.
|
|
93
|
+
"""Build an Alembic config for stk's lineage plus the app's, if given.
|
|
94
|
+
|
|
95
|
+
`app` is the app, or its import name (`"myapp"`) with an explicit `url`, so a
|
|
96
|
+
test can migrate a throwaway database before it builds the app.
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(app, str) and url is None:
|
|
99
|
+
raise ValueError("pass url when app is an import name")
|
|
79
100
|
config = AlembicConfig()
|
|
80
101
|
config.set_main_option("script_location", str(SCRIPT_DIR))
|
|
81
102
|
config.set_main_option("path_separator", "os")
|
|
@@ -5,7 +5,7 @@ import logging
|
|
|
5
5
|
from alembic import context
|
|
6
6
|
from sqlalchemy import engine_from_config, pool
|
|
7
7
|
|
|
8
|
-
from stk.migrations import get_target_metadata, include_object
|
|
8
|
+
from stk.migrations import get_target_metadata, include_object, render_item
|
|
9
9
|
|
|
10
10
|
config = context.config
|
|
11
11
|
|
|
@@ -25,6 +25,7 @@ def run_migrations_offline() -> None:
|
|
|
25
25
|
compare_type=True,
|
|
26
26
|
compare_server_default=True,
|
|
27
27
|
include_object=include_object,
|
|
28
|
+
render_item=render_item,
|
|
28
29
|
render_as_batch=url.startswith("sqlite"),
|
|
29
30
|
)
|
|
30
31
|
|
|
@@ -44,6 +45,7 @@ def run_migrations_online() -> None:
|
|
|
44
45
|
compare_type=True,
|
|
45
46
|
compare_server_default=True,
|
|
46
47
|
include_object=include_object,
|
|
48
|
+
render_item=render_item,
|
|
47
49
|
# SQLite alters tables by copying them; batch mode does that for us.
|
|
48
50
|
render_as_batch=connection.dialect.name == "sqlite",
|
|
49
51
|
)
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<meta name="description" content="A framework for the next decade">
|
|
9
9
|
<meta name="author" content="{{ config.APP_NAME }}">
|
|
10
10
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
{{ import_map() }}
|
|
11
12
|
<link rel="icon" href="/static/img/favicon.ico" type="image/x-icon">
|
|
12
13
|
|
|
13
14
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/vuetify.min.css') }}">
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""An app composes stk: its files win over its extensions', which win over stk's."""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import importlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
4
7
|
import sys
|
|
5
8
|
import tempfile
|
|
6
9
|
import textwrap
|
|
@@ -21,6 +24,8 @@ FILES = {
|
|
|
21
24
|
"""
|
|
22
25
|
),
|
|
23
26
|
"tapp/static/css/app.css": "/* the app's */",
|
|
27
|
+
"tapp/static/js/page.js": 'import "./shared.js";',
|
|
28
|
+
"tapp/static/js/shared.js": "// the app's, shadowing the extension's",
|
|
24
29
|
"tapp/first/__init__.py": textwrap.dedent(
|
|
25
30
|
"""\
|
|
26
31
|
from tapp.first import models
|
|
@@ -36,6 +41,8 @@ FILES = {
|
|
|
36
41
|
"tapp/first/templates/index.html": "the extension's, shadowed by the app's",
|
|
37
42
|
"tapp/first/static/css/app.css": "/* the first extension's */",
|
|
38
43
|
"tapp/first/static/css/ext.css": "/* first */",
|
|
44
|
+
"tapp/first/static/js/shared.js": "// the first extension's",
|
|
45
|
+
"tapp/first/static/js/lib/util.mjs": "export const x = 1;",
|
|
39
46
|
"tapp/second/__init__.py": textwrap.dedent(
|
|
40
47
|
"""\
|
|
41
48
|
def init_app(app):
|
|
@@ -107,6 +114,25 @@ class CompositionTest(unittest.IsolatedAsyncioTestCase):
|
|
|
107
114
|
titles[:5], ["Main", "Dashboard", "First", "Second", "Account"]
|
|
108
115
|
)
|
|
109
116
|
|
|
117
|
+
def test_app_navigation_is_the_apps_entries_alone(self):
|
|
118
|
+
titles = [i["title"] for i in self.app.jinja_env.globals["app_navigation"]()]
|
|
119
|
+
self.assertEqual(titles, ["First", "Second"])
|
|
120
|
+
|
|
121
|
+
async def test_import_map_versions_every_module_as_it_will_be_served(self):
|
|
122
|
+
page = await (await self.app.test_client().get("/")).get_data(as_text=True)
|
|
123
|
+
tag = re.search(r'<script type="importmap">(.*?)</script>', page)
|
|
124
|
+
imports = json.loads(tag.group(1))["imports"]
|
|
125
|
+
for name in ("js/page.js", "js/shared.js", "js/lib/util.mjs", "js/config.js"):
|
|
126
|
+
self.assertRegex(imports[f"/static/{name}"], r"\?v=[0-9a-f]{12}$")
|
|
127
|
+
async with self.app.test_request_context("/"):
|
|
128
|
+
from quart import url_for
|
|
129
|
+
|
|
130
|
+
shared = url_for("static", filename="js/shared.js")
|
|
131
|
+
self.assertEqual(imports["/static/js/shared.js"], shared)
|
|
132
|
+
app_digest = hashlib.sha256(b"// the app's, shadowing the extension's")
|
|
133
|
+
self.assertTrue(shared.endswith(app_digest.hexdigest()[:12]))
|
|
134
|
+
self.assertNotIn("/static/css/app.css", imports)
|
|
135
|
+
|
|
110
136
|
def test_extension_models_load_before_init_app(self):
|
|
111
137
|
self.assertTrue(self.app.config["SEEN_MODELS_FIRST"])
|
|
112
138
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""`stk db check` must notice a model that no migration has caught up with."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
import unittest
|
|
8
|
+
from pathlib import Path
|
|
7
9
|
|
|
8
10
|
# stk's own lineage against stk's own models, on a throwaway SQLite database. A
|
|
9
11
|
# fresh interpreter: other tests load the example app, whose tables would join
|
|
@@ -46,5 +48,33 @@ class MigrationDriftTest(unittest.TestCase):
|
|
|
46
48
|
self.assertEqual(drift("probe"), ["add_table: drift_probe"])
|
|
47
49
|
|
|
48
50
|
|
|
51
|
+
# Migrate by import name alone: the app module is never imported, so a test can
|
|
52
|
+
# migrate before it builds its app.
|
|
53
|
+
BY_NAME = """
|
|
54
|
+
import sys
|
|
55
|
+
from alembic import command
|
|
56
|
+
from sqlalchemy import create_engine, inspect
|
|
57
|
+
from stk.migrations import build_alembic_config, throwaway_database_url
|
|
58
|
+
|
|
59
|
+
with throwaway_database_url("sqlite:///unused.db", sync=True) as url:
|
|
60
|
+
command.upgrade(build_alembic_config("minimal", url, quiet=True), "heads")
|
|
61
|
+
tables = inspect(create_engine(url)).get_table_names()
|
|
62
|
+
print("note" in tables, "minimal.app" in sys.modules)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MigrateByImportNameTest(unittest.TestCase):
|
|
67
|
+
def test_app_lineage_migrates_without_an_app_object(self):
|
|
68
|
+
example = Path(__file__).resolve().parent.parent / "examples" / "minimal"
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
[sys.executable, "-c", BY_NAME],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
check=True,
|
|
74
|
+
env={**os.environ, "PYTHONPATH": str(example)},
|
|
75
|
+
)
|
|
76
|
+
self.assertEqual(result.stdout.split(), ["True", "False"])
|
|
77
|
+
|
|
78
|
+
|
|
49
79
|
if __name__ == "__main__":
|
|
50
80
|
unittest.main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""An autogenerated revision imports the modules of the types it renders by path."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import textwrap
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
FILES = {
|
|
12
|
+
"pyproject.toml": textwrap.dedent(
|
|
13
|
+
"""\
|
|
14
|
+
[tool.ruff.lint]
|
|
15
|
+
select = ["E", "W", "F", "I", "B", "C4", "UP"]
|
|
16
|
+
|
|
17
|
+
[tool.ruff.lint.isort]
|
|
18
|
+
known-first-party = ["tgen"]
|
|
19
|
+
"""
|
|
20
|
+
),
|
|
21
|
+
"tgen/__init__.py": "",
|
|
22
|
+
# A column type from another module of the app, rendered by its module path.
|
|
23
|
+
"tgen/types.py": textwrap.dedent(
|
|
24
|
+
"""\
|
|
25
|
+
from sqlalchemy.types import UserDefinedType
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Point(UserDefinedType):
|
|
29
|
+
cache_ok = True
|
|
30
|
+
|
|
31
|
+
def __init__(self, dim):
|
|
32
|
+
self.dim = dim
|
|
33
|
+
|
|
34
|
+
def get_col_spec(self, **kw):
|
|
35
|
+
return "POINT"
|
|
36
|
+
"""
|
|
37
|
+
),
|
|
38
|
+
"tgen/place/__init__.py": "def init_app(app):\n pass\n",
|
|
39
|
+
"tgen/place/models.py": textwrap.dedent(
|
|
40
|
+
"""\
|
|
41
|
+
from sqlalchemy import CheckConstraint, Integer
|
|
42
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
43
|
+
from stk.extensions import Base
|
|
44
|
+
|
|
45
|
+
from tgen.types import Point
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Place(Base):
|
|
49
|
+
__tablename__ = "place"
|
|
50
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
51
|
+
spot = mapped_column(Point(2))
|
|
52
|
+
rank = mapped_column(Integer, CheckConstraint("rank > 0", name="rank"))
|
|
53
|
+
"""
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# A fresh interpreter: the app's table would otherwise join shared metadata.
|
|
58
|
+
SCRIPT = """
|
|
59
|
+
import sys
|
|
60
|
+
from alembic import command
|
|
61
|
+
import tgen.place
|
|
62
|
+
from stk.app import Stk
|
|
63
|
+
from stk.cli.database import generate_revision
|
|
64
|
+
from stk.migrations import build_alembic_config
|
|
65
|
+
from stk.settings import TestingConfig
|
|
66
|
+
|
|
67
|
+
class Config(TestingConfig):
|
|
68
|
+
SECRET_KEY = SECURITY_PASSWORD_SALT = "test"
|
|
69
|
+
SQLALCHEMY_DATABASE_URI = "sqlite+aiosqlite:///" + sys.argv[1]
|
|
70
|
+
|
|
71
|
+
app = Stk("tgen", config=Config, extensions=[tgen.place])
|
|
72
|
+
command.upgrade(build_alembic_config(app, quiet=True), "heads")
|
|
73
|
+
print(generate_revision(app, "place", quiet=True).path)
|
|
74
|
+
command.upgrade(build_alembic_config(app, quiet=True), "heads")
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RevisionImportsTest(unittest.TestCase):
|
|
79
|
+
def test_revision_imports_app_types_and_passes_ruff(self):
|
|
80
|
+
with tempfile.TemporaryDirectory(prefix="stk-rev-") as root:
|
|
81
|
+
for name, text in FILES.items():
|
|
82
|
+
path = Path(root) / name
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
path.write_text(text)
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
[sys.executable, "-c", SCRIPT, f"{root}/app.db"],
|
|
87
|
+
cwd=root,
|
|
88
|
+
env={**os.environ, "PYTHONPATH": root},
|
|
89
|
+
capture_output=True,
|
|
90
|
+
text=True,
|
|
91
|
+
)
|
|
92
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
93
|
+
revision = Path(result.stdout.strip().splitlines()[-1]).read_text()
|
|
94
|
+
lint = subprocess.run(
|
|
95
|
+
[sys.executable, "-m", "ruff", "check", "--no-cache", root],
|
|
96
|
+
cwd=root,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
self.assertIn("\nimport tgen.types\n", revision)
|
|
102
|
+
self.assertIn("tgen.types.Point(2)", revision)
|
|
103
|
+
self.assertEqual(lint.returncode, 0, lint.stdout)
|
|
104
|
+
self.assertIn("place.rank: autogenerate leaves out", result.stdout)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
unittest.main()
|
|
@@ -329,7 +329,8 @@ class InitAndNewTests(unittest.TestCase):
|
|
|
329
329
|
}
|
|
330
330
|
self.assertEqual(assigned["branch_labels"], ("acme",))
|
|
331
331
|
self.assertEqual(assigned["depends_on"], "stk_0001")
|
|
332
|
-
|
|
332
|
+
# Formatted by the app's ruff config.
|
|
333
|
+
self.assertIn('op.create_table(\n "widget",', revision.read_text())
|
|
333
334
|
|
|
334
335
|
def test_both_lineages_match_the_models(self):
|
|
335
336
|
# gadget was scaffolded without a migration, so it is the only drift.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|