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.
Files changed (163) hide show
  1. {stkit-17.0.2 → stkit-17.1.0}/CHANGELOG.md +26 -0
  2. {stkit-17.0.2 → stkit-17.1.0}/PKG-INFO +1 -1
  3. {stkit-17.0.2 → stkit-17.1.0}/pyproject.toml +1 -1
  4. {stkit-17.0.2 → stkit-17.1.0}/stk/__init__.py +1 -1
  5. {stkit-17.0.2 → stkit-17.1.0}/stk/app.py +32 -1
  6. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/database.py +55 -23
  7. {stkit-17.0.2 → stkit-17.1.0}/stk/migrations/__init__.py +22 -1
  8. {stkit-17.0.2 → stkit-17.1.0}/stk/migrations/env.py +3 -1
  9. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/layout.html +1 -0
  10. {stkit-17.0.2 → stkit-17.1.0}/tests/test_composition.py +26 -0
  11. {stkit-17.0.2 → stkit-17.1.0}/tests/test_migration_drift.py +30 -0
  12. stkit-17.1.0/tests/test_revision_imports.py +108 -0
  13. {stkit-17.0.2 → stkit-17.1.0}/tests/test_scaffolder.py +2 -1
  14. {stkit-17.0.2 → stkit-17.1.0}/.gitignore +0 -0
  15. {stkit-17.0.2 → stkit-17.1.0}/LICENSE +0 -0
  16. {stkit-17.0.2 → stkit-17.1.0}/README.md +0 -0
  17. {stkit-17.0.2 → stkit-17.1.0}/SECURITY.md +0 -0
  18. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/.env-sample +0 -0
  19. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/.gitignore +0 -0
  20. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/README.md +0 -0
  21. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/__init__.py +0 -0
  22. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/app.py +0 -0
  23. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/migrations/364232ae77d5_add_note.py +0 -0
  24. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/note/__init__.py +0 -0
  25. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/note/models.py +0 -0
  26. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/note/templates/note/index.html +0 -0
  27. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/note/views.py +0 -0
  28. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/settings.py +0 -0
  29. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/minimal/templates/layout.html +0 -0
  30. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/pyproject.toml +0 -0
  31. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/tests/__init__.py +0 -0
  32. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/tests/test_app.py +0 -0
  33. {stkit-17.0.2 → stkit-17.1.0}/examples/minimal/uv.lock +0 -0
  34. {stkit-17.0.2 → stkit-17.1.0}/stk/__main__.py +0 -0
  35. {stkit-17.0.2 → stkit-17.1.0}/stk/agent_login.py +0 -0
  36. {stkit-17.0.2 → stkit-17.1.0}/stk/checks.py +0 -0
  37. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/__init__.py +0 -0
  38. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/agent.py +0 -0
  39. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/base.py +0 -0
  40. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/develop.py +0 -0
  41. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/doctor.py +0 -0
  42. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/main.py +0 -0
  43. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/reports.py +0 -0
  44. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/smoke.py +0 -0
  45. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/users.py +0 -0
  46. {stkit-17.0.2 → stkit-17.1.0}/stk/cli/watch.py +0 -0
  47. {stkit-17.0.2 → stkit-17.1.0}/stk/extensions.py +0 -0
  48. {stkit-17.0.2 → stkit-17.1.0}/stk/migrations/script.py.mako +0 -0
  49. {stkit-17.0.2 → stkit-17.1.0}/stk/migrations/versions/stk_0001_initial_schema.py +0 -0
  50. {stkit-17.0.2 → stkit-17.1.0}/stk/portal/views.py +0 -0
  51. {stkit-17.0.2 → stkit-17.1.0}/stk/public/__init__.py +0 -0
  52. {stkit-17.0.2 → stkit-17.1.0}/stk/public/models.py +0 -0
  53. {stkit-17.0.2 → stkit-17.1.0}/stk/public/views.py +0 -0
  54. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/__init__.py +0 -0
  55. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/CLAUDE.md +0 -0
  56. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/Dockerfile +0 -0
  57. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/README.md +0 -0
  58. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/__app__/__init__.py +0 -0
  59. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/__app__/app.py +0 -0
  60. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/__app__/settings.py +0 -0
  61. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-api/SKILL.md +0 -0
  62. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +0 -0
  63. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +0 -0
  64. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +0 -0
  65. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +0 -0
  66. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/docker-compose.yml +0 -0
  67. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/dockerignore +0 -0
  68. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/env-sample +0 -0
  69. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/github/workflows/checks.yml +0 -0
  70. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/gitignore +0 -0
  71. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/nginx/nginx.conf +0 -0
  72. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/nginx/stk.conf +0 -0
  73. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/postgres/init-db.sh +0 -0
  74. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/pyproject.toml +0 -0
  75. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/tests/__init__.py +0 -0
  76. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/app/tests/test_app.py +0 -0
  77. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/generator.py +0 -0
  78. {stkit-17.0.2 → stkit-17.1.0}/stk/scaffold/templates.py +0 -0
  79. {stkit-17.0.2 → stkit-17.1.0}/stk/security.py +0 -0
  80. {stkit-17.0.2 → stkit-17.1.0}/stk/settings.py +0 -0
  81. {stkit-17.0.2 → stkit-17.1.0}/stk/shell.py +0 -0
  82. {stkit-17.0.2 → stkit-17.1.0}/stk/static/VERSIONS.txt +0 -0
  83. {stkit-17.0.2 → stkit-17.1.0}/stk/static/css/app.css +0 -0
  84. {stkit-17.0.2 → stkit-17.1.0}/stk/static/css/layout.css +0 -0
  85. {stkit-17.0.2 → stkit-17.1.0}/stk/static/css/vuetify.min.css +0 -0
  86. {stkit-17.0.2 → stkit-17.1.0}/stk/static/icons/fonts/tabler-icons.woff2 +0 -0
  87. {stkit-17.0.2 → stkit-17.1.0}/stk/static/icons/tabler-icons.min.css +0 -0
  88. {stkit-17.0.2 → stkit-17.1.0}/stk/static/img/auth-bg.webp +0 -0
  89. {stkit-17.0.2 → stkit-17.1.0}/stk/static/img/favicon.ico +0 -0
  90. {stkit-17.0.2 → stkit-17.1.0}/stk/static/img/stk.svg +0 -0
  91. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/axios.min.js +0 -0
  92. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/NotificationDropdown.js +0 -0
  93. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/ThemeSwitcher.js +0 -0
  94. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/TransitionExpand.js +0 -0
  95. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/VerticalNavGroup.js +0 -0
  96. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/VerticalNavLink.js +0 -0
  97. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/VerticalNavSectionTitle.js +0 -0
  98. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/components/index.js +0 -0
  99. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/config.js +0 -0
  100. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/vue.min.js +0 -0
  101. {stkit-17.0.2 → stkit-17.1.0}/stk/static/js/vuetify.min.js +0 -0
  102. {stkit-17.0.2 → stkit-17.1.0}/stk/static/robots.txt +0 -0
  103. {stkit-17.0.2 → stkit-17.1.0}/stk/tasks/__init__.py +0 -0
  104. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/401.html +0 -0
  105. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/404.html +0 -0
  106. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/500.html +0 -0
  107. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/_brand.html +0 -0
  108. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/auth_layout.html +0 -0
  109. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/cms/activities.html +0 -0
  110. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/cms/roles.html +0 -0
  111. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/cms/users.html +0 -0
  112. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/dashboard.html +0 -0
  113. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/index.html +0 -0
  114. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/login-layout.html +0 -0
  115. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/_macros.html +0 -0
  116. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/_messages.html +0 -0
  117. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/change_password.html +0 -0
  118. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/confirm_email.html +0 -0
  119. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/change_notice.html +0 -0
  120. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/change_notice.txt +0 -0
  121. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/confirmation_instructions.html +0 -0
  122. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/confirmation_instructions.txt +0 -0
  123. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/login_instructions.html +0 -0
  124. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/login_instructions.txt +0 -0
  125. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/reset_instructions.html +0 -0
  126. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/reset_instructions.txt +0 -0
  127. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/reset_notice.html +0 -0
  128. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/reset_notice.txt +0 -0
  129. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/welcome.html +0 -0
  130. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/email/welcome.txt +0 -0
  131. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/forgot_password.html +0 -0
  132. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/login_user.html +0 -0
  133. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/mf_recovery.html +0 -0
  134. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/mf_recovery_codes.html +0 -0
  135. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/register_user.html +0 -0
  136. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/reset_password.html +0 -0
  137. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/send_confirmation.html +0 -0
  138. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/send_login.html +0 -0
  139. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/two_factor_select.html +0 -0
  140. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/two_factor_setup.html +0 -0
  141. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/two_factor_verify_code.html +0 -0
  142. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/wan_register.html +0 -0
  143. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/wan_signin.html +0 -0
  144. {stkit-17.0.2 → stkit-17.1.0}/stk/templates/security/wan_verify.html +0 -0
  145. {stkit-17.0.2 → stkit-17.1.0}/stk/user/__init__.py +0 -0
  146. {stkit-17.0.2 → stkit-17.1.0}/stk/user/forms.py +0 -0
  147. {stkit-17.0.2 → stkit-17.1.0}/stk/user/models.py +0 -0
  148. {stkit-17.0.2 → stkit-17.1.0}/stk/user/views.py +0 -0
  149. {stkit-17.0.2 → stkit-17.1.0}/stk/utils/email.py +0 -0
  150. {stkit-17.0.2 → stkit-17.1.0}/stk/utils/ratelimit.py +0 -0
  151. {stkit-17.0.2 → stkit-17.1.0}/stk/websocket.py +0 -0
  152. {stkit-17.0.2 → stkit-17.1.0}/tests/test_agent_operability.py +0 -0
  153. {stkit-17.0.2 → stkit-17.1.0}/tests/test_app_index.py +0 -0
  154. {stkit-17.0.2 → stkit-17.1.0}/tests/test_branding.py +0 -0
  155. {stkit-17.0.2 → stkit-17.1.0}/tests/test_builder_ux.py +0 -0
  156. {stkit-17.0.2 → stkit-17.1.0}/tests/test_cli.py +0 -0
  157. {stkit-17.0.2 → stkit-17.1.0}/tests/test_csrf_exempt.py +0 -0
  158. {stkit-17.0.2 → stkit-17.1.0}/tests/test_error_handler.py +0 -0
  159. {stkit-17.0.2 → stkit-17.1.0}/tests/test_production_security.py +0 -0
  160. {stkit-17.0.2 → stkit-17.1.0}/tests/test_security_compatibility.py +0 -0
  161. {stkit-17.0.2 → stkit-17.1.0}/tests/test_shell.py +0 -0
  162. {stkit-17.0.2 → stkit-17.1.0}/tests/test_smoke_pages.py +0 -0
  163. {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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stkit
3
- Version: 17.0.2
3
+ Version: 17.1.0
4
4
  Summary: An async Quart framework optimized for AI-assisted development workflows
5
5
  Author-email: Level09 <hello@level09.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stkit"
3
- version = "17.0.2"
3
+ version = "17.1.0"
4
4
  description = "An async Quart framework optimized for AI-assisted development workflows"
5
5
  authors = [
6
6
  {name = "Level09", email = "hello@level09.com"}
@@ -3,7 +3,7 @@ stk Framework
3
3
  A modern Python web framework optimized for AI-assisted development.
4
4
  """
5
5
 
6
- __version__ = "17.0.2"
6
+ __version__ = "17.1.0"
7
7
 
8
8
 
9
9
  def __getattr__(name):
@@ -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 quart import Quart, g, render_template, request, send_from_directory
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
- 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(
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
- depends_on=core_head,
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
- self.assertIn("create_table('widget'", revision.read_text())
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