tina4-python 3.13.35__tar.gz → 3.13.36__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 (157) hide show
  1. {tina4_python-3.13.35 → tina4_python-3.13.36}/PKG-INFO +1 -1
  2. {tina4_python-3.13.35 → tina4_python-3.13.36}/pyproject.toml +1 -1
  3. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/CLAUDE.md +11 -2
  4. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/router.py +14 -0
  6. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/server.py +82 -5
  7. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/dev_admin/__init__.py +56 -7
  8. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/js/tina4-dev-admin.js +44 -42
  9. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/js/tina4-dev-admin.min.js +44 -42
  10. {tina4_python-3.13.35 → tina4_python-3.13.36}/.gitignore +0 -0
  11. {tina4_python-3.13.35 → tina4_python-3.13.36}/README.md +0 -0
  12. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/__init__.py +0 -0
  21. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/cache.py +0 -0
  22. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/constants.py +0 -0
  23. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/events.py +0 -0
  24. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/middleware.py +0 -0
  25. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/rate_limiter.py +0 -0
  26. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/core/response.py +0 -0
  28. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/crud/__init__.py +0 -0
  29. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/__init__.py +0 -0
  30. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/adapter.py +0 -0
  31. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/connection.py +0 -0
  32. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/firebird.py +0 -0
  33. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/mongodb.py +0 -0
  34. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/mssql.py +0 -0
  35. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/mysql.py +0 -0
  36. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/postgres.py +0 -0
  38. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/database/sqlite.py +0 -0
  39. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/debug/__init__.py +0 -0
  40. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/debug/error_overlay.py +0 -0
  41. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/dev_admin/metrics.py +0 -0
  42. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/dev_admin/plan.py +0 -0
  43. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/dev_admin/project_index.py +0 -0
  44. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/docs.py +0 -0
  45. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/dotenv/__init__.py +0 -0
  46. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/env.py +0 -0
  47. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/frond/FROND.md +0 -0
  48. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/frond/__init__.py +0 -0
  49. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/frond/engine.py +0 -0
  50. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/auth/meta.json +0 -0
  51. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  52. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/database/meta.json +0 -0
  53. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  54. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/error-overlay/meta.json +0 -0
  55. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  56. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/orm/meta.json +0 -0
  57. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  58. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  59. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/queue/meta.json +0 -0
  60. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  61. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/rest-api/meta.json +0 -0
  62. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  63. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/templates/meta.json +0 -0
  64. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  65. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  66. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/graphql/__init__.py +0 -0
  67. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/i18n/__init__.py +0 -0
  68. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/mcp/__init__.py +0 -0
  69. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/mcp/protocol.py +0 -0
  70. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/mcp/tools.py +0 -0
  71. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/orm/fields.py +0 -0
  76. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/orm/model.py +0 -0
  77. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/__feedback/widget.js +0 -0
  78. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/css/tina4.css +0 -0
  79. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/css/tina4.min.css +0 -0
  80. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/favicon.ico +0 -0
  81. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/images/logo.svg +0 -0
  82. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  83. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/js/frond.js +0 -0
  84. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/js/frond.min.js +0 -0
  85. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.35 → tina4_python-3.13.36}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.35
3
+ Version: 3.13.36
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.35"
3
+ version = "3.13.36"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -409,9 +409,18 @@ Set `TINA4_DEBUG=true` in `.env` to enable development features:
409
409
  - **CSS hot-reload** — SCSS/CSS changes refresh stylesheets without full page reload
410
410
  - **SCSS auto-compile** — `.scss` files in `src/scss/` are compiled to `src/public/css/` on save
411
411
  - **Error overlay** — Runtime errors display a rich, syntax-highlighted overlay in the browser
412
- - **Route re-discovery** — `POST /__dev/api/reload` re-runs auto-discover, so new files in `src/routes/`, `src/orm/`, or `src/app/` register without a server restart. Existing modules are NOT re-imported for code changes inside an already-loaded file, restart the server.
412
+ - **Route re-discovery & hot-reload** — `POST /__dev/api/reload` re-runs auto-discover. New files in `src/routes/`, `src/orm/`, or `src/app/` register without a server restart, AND changed `.py` files under `src/` are re-imported in-process (mtime-tracked) so editing an existing route hot-reloads its handler live — no restart needed. The Router replaces a re-registered `(method, path)` in place, so the fresh handler wins instead of being shadowed by the stale one. Only `src/` modules are ever re-imported; framework (`tina4_python.*`) modules are never touched. Honest caveat: a symbol imported BY NAME into another *unchanged* module (e.g. `from src.orm.User import User` inside a route file that itself didn't change) or an ORM class identity captured across modules may still need a restart — the edited file re-executes, but a different module that grabbed the old reference earlier keeps it until it too re-imports.
413
413
 
414
- DevReload connects via WebSocket at `/__dev_reload`. No configuration needed.
414
+ ### How DevReload works (WebSocket-primary)
415
+
416
+ DevReload is **WebSocket-primary** — the reload is instant, not polled:
417
+
418
+ 1. The `tina4` Rust CLI watches `src/`, `migrations/`, `.env` and, on a real change, POSTs `/__dev/api/reload` to the **running** server. The CLI does **not** restart the worker process.
419
+ 2. The server re-runs auto-discover — registering new `src/` files and re-importing changed ones **in-process** (mtime-tracked, `src/` only; framework modules never), so the worker keeps the same PID — then bumps its reload counter.
420
+ 3. The server **broadcasts** a JSON message `{type, file, mtime}` to every browser connected on the `/__dev_reload` WebSocket (`type` is `"css"` for stylesheet changes, else `"reload"`). The dev-toolbar client and the dev-admin dashboard both connect here and act on it instantly: CSS changes swap `<link rel=stylesheet>` hrefs with a cache-bust query; everything else does a full `location.reload()`.
421
+ 4. **Poll is fallback only.** The injected toolbar client stops polling the moment the socket connects, and only restarts the `/__dev/api/mtime` poll (every 3 s) when the socket drops — reconnecting after ~2 s. In normal operation there is no polling.
422
+
423
+ The `/__dev_reload` WebSocket route is registered automatically when `TINA4_DEBUG=true`. No configuration needed. Running without the Rust CLI (e.g. Docker, `TINA4_OVERRIDE_CLIENT=true`) means no automatic reload.
415
424
 
416
425
  ## Routing
417
426
 
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.35"
11
+ __version__ = "3.13.36"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -340,6 +340,20 @@ class Router:
340
340
  "swagger_meta": swagger_meta or options.get("swagger_meta", {}),
341
341
  "template": template or options.get("template"),
342
342
  }
343
+ # Replace semantics: re-registering the same (method, path) overwrites
344
+ # the existing entry in place rather than appending a second one.
345
+ # This is what makes dev hot-reload work — when a changed module is
346
+ # re-imported, its @get("/x") decorator runs again with a fresh handler,
347
+ # and ``match()`` returns the FIRST match, so a stale leftover would
348
+ # otherwise shadow the new handler forever. Overwriting keeps the
349
+ # registry free of duplicates and ensures the latest handler wins.
350
+ # Distinct (method, path) pairs are untouched — only an exact dup
351
+ # collapses onto the prior slot, preserving its position/order.
352
+ for i, existing in enumerate(_routes):
353
+ if existing["method"] == m and existing["path"] == path:
354
+ _routes[i] = route
355
+ Log.debug(f"Route replaced: {m} {path} (auth={'required' if auth_required else 'public'})")
356
+ return RouteRef(route)
343
357
  _routes.append(route)
344
358
  Log.debug(f"Route registered: {m} {path} (auth={'required' if auth_required else 'public'})")
345
359
  return RouteRef(route)
@@ -48,12 +48,30 @@ def background(callback, interval: float = 1.0):
48
48
  _background_tasks.append({"callback": callback, "interval": interval})
49
49
 
50
50
 
51
+ # module_name → source-file mtime at the last (re)import. Drives the
52
+ # changed-file detection in ``_auto_discover``: a file whose mtime is newer
53
+ # than the recorded value is re-executed in place, so editing an existing
54
+ # route hot-reloads on /__dev/api/reload without a server restart. Files we
55
+ # have never imported are absent from the map.
56
+ _discovered_mtimes: dict[str, float] = {}
57
+
58
+
51
59
  def _auto_discover(root_dir: str = "src"):
52
60
  """Auto-import all .py files in ``root_dir`` to trigger route decorators.
53
61
 
54
- Idempotent and re-runnable: skips modules already in ``sys.modules`` so
55
- re-discovery on /__dev/api/reload is cheap. New files added after server
56
- boot get picked up on the next reload signal.
62
+ Idempotent and re-runnable so re-discovery on /__dev/api/reload is cheap:
63
+
64
+ * **New** module (not in ``sys.modules``) import it, record its mtime.
65
+ * **Changed** module (in ``sys.modules`` and its source mtime is newer than
66
+ the recorded value) → re-execute it (``del sys.modules`` + re-import) so
67
+ edits to an existing route take effect. The Router replaces same-(method,
68
+ path) registrations, so the re-imported handler wins instead of being
69
+ shadowed by the stale one.
70
+ * **Unchanged** module → skipped (keeps the re-runnable property cheap).
71
+
72
+ Only modules discovered under ``root_dir`` are ever re-imported — framework
73
+ (``tina4_python.*``) and third-party modules are never deleted/re-imported,
74
+ which would be catastrophic for shared singletons and class identity.
57
75
 
58
76
  Import failures are recorded to ``data/.broken/`` so /health surfaces them
59
77
  instead of swallowing them into a console line nobody reads.
@@ -62,6 +80,14 @@ def _auto_discover(root_dir: str = "src"):
62
80
  if not root.is_dir():
63
81
  return
64
82
 
83
+ # The package prefix every discovered module shares (e.g. "src"). The
84
+ # del+reimport path is gated on this so we can never evict a framework or
85
+ # third-party module from sys.modules even if a name somehow collides.
86
+ try:
87
+ root_pkg = root.relative_to(Path.cwd()).parts[0]
88
+ except (ValueError, IndexError):
89
+ root_pkg = root.name
90
+
65
91
  # Folders to skip — non-Python sub-trees inside src/.
66
92
  skip = {"public", "templates", "scss", "locales", "icons"}
67
93
  # Routes folder is special-cased so the user gets a clear warning when
@@ -91,9 +117,32 @@ def _auto_discover(root_dir: str = "src"):
91
117
  try:
92
118
  rel = py_file.relative_to(Path.cwd()).with_suffix("")
93
119
  module_name = ".".join(rel.parts)
120
+ try:
121
+ current_mtime = py_file.stat().st_mtime
122
+ except OSError:
123
+ current_mtime = 0.0
124
+
94
125
  if module_name not in sys.modules:
126
+ # New module — import and remember its mtime.
95
127
  importlib.import_module(module_name)
128
+ _discovered_mtimes[module_name] = current_mtime
96
129
  Log.debug(f"Loaded: {module_name}")
130
+ elif current_mtime > _discovered_mtimes.get(module_name, 0.0):
131
+ # Changed module — re-execute so edits to an existing route
132
+ # take effect in-process. Scope guard: only ever evict a module
133
+ # that lives under our discovery package. Deleting a
134
+ # tina4_python.* / third-party module would break shared
135
+ # singletons and class identity — never do that here.
136
+ if module_name == root_pkg or module_name.startswith(root_pkg + "."):
137
+ del sys.modules[module_name]
138
+ importlib.import_module(module_name)
139
+ _discovered_mtimes[module_name] = current_mtime
140
+ Log.info(f"Reloaded changed module: {module_name}")
141
+ else:
142
+ # Out-of-scope module changed — record mtime so we don't
143
+ # keep re-evaluating it, but do not re-import it.
144
+ _discovered_mtimes[module_name] = current_mtime
145
+ # Unchanged module → skip (keeps re-discovery cheap/idempotent).
97
146
  except Exception as e:
98
147
  Log.error(f"Failed to load {py_file}: {e}")
99
148
  _record_broken_import(py_file, e)
@@ -634,6 +683,30 @@ from tina4_python.websocket import WebSocketConnection, WebSocketManager
634
683
  _ws_manager = WebSocketManager()
635
684
 
636
685
 
686
+ async def _dev_reload_ws(connection, event, data):
687
+ """WebSocket handler for the dev-reload channel (/__dev_reload).
688
+
689
+ Connections are kept open and held by ``_ws_manager`` on the
690
+ ``/__dev_reload`` path so ``POST /__dev/api/reload`` can broadcast an
691
+ instant reload to every browser. The framework never pushes anything from
692
+ the client side — incoming frames are ignored; the open socket is the
693
+ whole point. This restores the documented WebSocket-primary DevReload
694
+ design (the dashboard SPA and the injected dev-toolbar both connect here).
695
+ """
696
+ return
697
+
698
+
699
+ _dev_reload_ws_registered = [False]
700
+
701
+
702
+ def _register_dev_reload_ws() -> None:
703
+ """Register the /__dev_reload WebSocket route once (debug mode only)."""
704
+ if _dev_reload_ws_registered[0]:
705
+ return
706
+ Router.websocket("/__dev_reload", _dev_reload_ws)
707
+ _dev_reload_ws_registered[0] = True
708
+
709
+
637
710
  async def _handle_asgi_websocket(scope: dict, receive, send):
638
711
  """Handle ASGI WebSocket connections, dispatching to registered routes."""
639
712
  path = scope.get("path", "/")
@@ -2183,8 +2256,12 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
2183
2256
  is_debug = is_truthy(os.environ.get("TINA4_DEBUG", ""))
2184
2257
 
2185
2258
  # File watching is handled by the Rust CLI (tina4 serve). The framework
2186
- # only needs to receive POST /__dev/api/reload and update the mtime counter
2187
- # so the browser's polling fallback triggers a refresh. No internal watcher.
2259
+ # only needs to receive POST /__dev/api/reload, re-import the changed
2260
+ # module in-process, and push an instant reload over the /__dev_reload
2261
+ # WebSocket. The mtime counter at /__dev/api/mtime is the polling
2262
+ # fallback for when that socket is down. No internal watcher.
2263
+ if is_debug:
2264
+ _register_dev_reload_ws()
2188
2265
 
2189
2266
  prod = None
2190
2267
  if not is_debug:
@@ -1979,6 +1979,25 @@ async def _api_reload(request, response):
1979
1979
  except Exception as e:
1980
1980
  Log.error(f"Re-discover on reload failed: {e}")
1981
1981
 
1982
+ # WebSocket-primary reload: push an instant message to every browser
1983
+ # connected on /__dev_reload. The toolbar client (and the dev-admin
1984
+ # dashboard) act on this immediately — the mtime poll above is only a
1985
+ # fallback for when the socket is down. CSS changes swap stylesheets;
1986
+ # everything else triggers a full page reload. We normalise the wire
1987
+ # `type` to "css"/"reload" so both clients react (the dashboard only
1988
+ # listens for reload/change/css), but the HTTP response still echoes the
1989
+ # caller's original type. Wrapped so a broadcast failure (or zero
1990
+ # clients) never 500s the reload endpoint.
1991
+ ws_type = "css" if reload_type == "css" else "reload"
1992
+ try:
1993
+ from tina4_python.core.server import _ws_manager
1994
+ await _ws_manager.broadcast(
1995
+ json.dumps({"type": ws_type, "file": _reload_file[0], "mtime": _reload_mtime[0]}),
1996
+ path="/__dev_reload",
1997
+ )
1998
+ except Exception as e:
1999
+ Log.error(f"Dev-reload WebSocket broadcast failed: {e}")
2000
+
1982
2001
  return response({"ok": True, "type": reload_type})
1983
2002
 
1984
2003
 
@@ -2150,16 +2169,22 @@ def render_dev_toolbar(method: str, path: str, matched_pattern: str,
2150
2169
  </script>
2151
2170
  <script>
2152
2171
  {'(function(){})();' if no_reload else f"""(function(){{
2153
- var _t4_mtime=0,_t4_css_exts=['.css','.scss'],_t4_debounce=null;
2172
+ // WebSocket-primary dev reloader. The running server re-imports changed
2173
+ // src/ modules in-process and pushes a {{type,file,mtime}} message over
2174
+ // /__dev_reload — no respawn, instant refresh. The mtime poll below is a
2175
+ // FALLBACK only, started when the socket is down and stopped on connect.
2176
+ var _t4_css_exts=['.css','.scss'],_t4_debounce=null;
2154
2177
  var _t4_interval=parseInt('{poll_interval_ms}')||3000;
2178
+ var _t4_ws=null,_t4_poll_timer=null,_t4_mtime=null;
2155
2179
  function _t4_apply(d){{
2156
- var f=d.file||'';
2157
- var isCss=_t4_css_exts.some(function(e){{return f.endsWith(e)}});
2180
+ d=d||{{}};
2181
+ var f=d.file||'',t=d.type||'';
2182
+ var isCss=t==='css'||_t4_css_exts.some(function(e){{return f.endsWith(e)}});
2158
2183
  if(isCss){{
2159
2184
  var links=document.querySelectorAll('link[rel="stylesheet"]');
2160
2185
  links.forEach(function(l){{
2161
2186
  var href=l.getAttribute('href');
2162
- if(href){{l.setAttribute('href',href.split('?')[0]+'?_t4='+d.mtime)}}
2187
+ if(href){{l.setAttribute('href',href.split('?')[0]+'?_t4='+(d.mtime||Date.now()))}}
2163
2188
  }});
2164
2189
  }}else{{
2165
2190
  location.reload();
@@ -2167,15 +2192,39 @@ def render_dev_toolbar(method: str, path: str, matched_pattern: str,
2167
2192
  }}
2168
2193
  function _t4_poll(){{
2169
2194
  fetch('/__dev/api/mtime').then(function(r){{return r.json()}}).then(function(d){{
2170
- if(!_t4_mtime){{_t4_mtime=d.mtime;return;}}
2171
- if(d.mtime>_t4_mtime){{
2195
+ if(_t4_mtime===null){{_t4_mtime=d.mtime;return;}}
2196
+ if(d.mtime!==_t4_mtime){{
2172
2197
  _t4_mtime=d.mtime;
2173
2198
  if(_t4_debounce)clearTimeout(_t4_debounce);
2174
2199
  _t4_debounce=setTimeout(function(){{_t4_apply(d);}},500);
2175
2200
  }}
2176
2201
  }}).catch(function(){{}});
2177
2202
  }}
2178
- setInterval(_t4_poll,_t4_interval);
2203
+ function _t4_startPoll(){{
2204
+ if(_t4_poll_timer)return;
2205
+ _t4_mtime=null;
2206
+ _t4_poll_timer=setInterval(_t4_poll,_t4_interval);
2207
+ }}
2208
+ function _t4_stopPoll(){{
2209
+ if(_t4_poll_timer){{clearInterval(_t4_poll_timer);_t4_poll_timer=null;}}
2210
+ }}
2211
+ function _t4_connect(){{
2212
+ var url=(location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/__dev_reload';
2213
+ try{{_t4_ws=new WebSocket(url);}}catch(_){{_t4_startPoll();return;}}
2214
+ _t4_ws.addEventListener('open',function(){{_t4_stopPoll();}});
2215
+ _t4_ws.addEventListener('message',function(ev){{
2216
+ var d=null;
2217
+ try{{d=typeof ev.data==='string'?JSON.parse(ev.data):null;}}catch(_){{}}
2218
+ if(!d)return;
2219
+ if(d.type==='reload'||d.type==='change'||d.type==='css'){{
2220
+ if(_t4_debounce)clearTimeout(_t4_debounce);
2221
+ _t4_debounce=setTimeout(function(){{_t4_apply(d);}},150);
2222
+ }}
2223
+ }});
2224
+ _t4_ws.addEventListener('close',function(){{_t4_ws=null;_t4_startPoll();setTimeout(_t4_connect,2000);}});
2225
+ _t4_ws.addEventListener('error',function(){{try{{_t4_ws&&_t4_ws.close();}}catch(_){{}}}});
2226
+ }}
2227
+ _t4_connect();
2179
2228
  }})();"""}
2180
2229
  function tina4VersionModal(){{
2181
2230
  var m=document.getElementById('tina4-ver-modal');