tina4-python 3.13.34__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.34 → tina4_python-3.13.36}/PKG-INFO +1 -1
  2. {tina4_python-3.13.34 → tina4_python-3.13.36}/pyproject.toml +1 -1
  3. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/CLAUDE.md +11 -2
  4. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/router.py +14 -0
  6. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/server.py +82 -5
  7. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/dev_admin/__init__.py +102 -7
  8. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/mcp/tools.py +1 -1
  9. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/js/tina4-dev-admin.js +44 -42
  10. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/js/tina4-dev-admin.min.js +44 -42
  11. {tina4_python-3.13.34 → tina4_python-3.13.36}/.gitignore +0 -0
  12. {tina4_python-3.13.34 → tina4_python-3.13.36}/README.md +0 -0
  13. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/HtmlElement.py +0 -0
  14. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/Testing.py +0 -0
  15. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/ai/__init__.py +0 -0
  16. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/api/__init__.py +0 -0
  17. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/auth/__init__.py +0 -0
  18. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/cache/__init__.py +0 -0
  19. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/cli/__init__.py +0 -0
  20. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/container/__init__.py +0 -0
  21. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/__init__.py +0 -0
  22. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/cache.py +0 -0
  23. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/constants.py +0 -0
  24. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/events.py +0 -0
  25. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/middleware.py +0 -0
  26. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/rate_limiter.py +0 -0
  27. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/request.py +0 -0
  28. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/core/response.py +0 -0
  29. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/crud/__init__.py +0 -0
  30. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/__init__.py +0 -0
  31. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/adapter.py +0 -0
  32. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/connection.py +0 -0
  33. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/firebird.py +0 -0
  34. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/mongodb.py +0 -0
  35. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/mssql.py +0 -0
  36. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/mysql.py +0 -0
  37. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/odbc.py +0 -0
  38. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/postgres.py +0 -0
  39. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/database/sqlite.py +0 -0
  40. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/debug/__init__.py +0 -0
  41. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/debug/error_overlay.py +0 -0
  42. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/dev_admin/metrics.py +0 -0
  43. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/dev_admin/plan.py +0 -0
  44. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/dev_admin/project_index.py +0 -0
  45. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/docs.py +0 -0
  46. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/dotenv/__init__.py +0 -0
  47. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/env.py +0 -0
  48. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/frond/FROND.md +0 -0
  49. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/frond/__init__.py +0 -0
  50. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/frond/engine.py +0 -0
  51. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/auth/meta.json +0 -0
  52. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  53. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/database/meta.json +0 -0
  54. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  55. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/error-overlay/meta.json +0 -0
  56. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  57. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/orm/meta.json +0 -0
  58. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  59. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  60. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/queue/meta.json +0 -0
  61. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  62. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/rest-api/meta.json +0 -0
  63. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  64. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/templates/meta.json +0 -0
  65. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  66. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  67. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/graphql/__init__.py +0 -0
  68. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/i18n/__init__.py +0 -0
  69. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/mcp/__init__.py +0 -0
  70. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/mcp/protocol.py +0 -0
  71. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/orm/fields.py +0 -0
  76. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/orm/model.py +0 -0
  77. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/__feedback/widget.js +0 -0
  78. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/css/tina4.css +0 -0
  79. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/css/tina4.min.css +0 -0
  80. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/favicon.ico +0 -0
  81. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/images/logo.svg +0 -0
  82. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  83. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/js/frond.js +0 -0
  84. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/js/frond.min.js +0 -0
  85. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.34 → tina4_python-3.13.36}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.34 → 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.34
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.34"
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.34"
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:
@@ -485,6 +485,12 @@ def get_api_handlers() -> dict:
485
485
  # @mcp_tool decorator appear in both immediately.
486
486
  "/__dev/api/mcp/tools": ("GET", _api_mcp_tools),
487
487
  "/__dev/api/mcp/call": ("POST", _api_mcp_call),
488
+ # JSON-RPC + SSE surface for real MCP clients (Claude Desktop/Code).
489
+ # Same registry as the REST shim above; this is what makes /__dev/mcp
490
+ # actually reachable as an MCP server (previously defined but unmounted).
491
+ "/__dev/mcp": ("POST", _api_mcp_rpc),
492
+ "/__dev/mcp/message": ("POST", _api_mcp_rpc),
493
+ "/__dev/mcp/sse": ("GET", _api_mcp_sse),
488
494
  # ── Scaffold REST shim ──
489
495
  # Wraps the tina4python CLI's `generate <kind> <name>` so the
490
496
  # + Route / + Model / + Migration / + Middleware buttons work
@@ -1973,6 +1979,25 @@ async def _api_reload(request, response):
1973
1979
  except Exception as e:
1974
1980
  Log.error(f"Re-discover on reload failed: {e}")
1975
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
+
1976
2001
  return response({"ok": True, "type": reload_type})
1977
2002
 
1978
2003
 
@@ -2144,16 +2169,22 @@ def render_dev_toolbar(method: str, path: str, matched_pattern: str,
2144
2169
  </script>
2145
2170
  <script>
2146
2171
  {'(function(){})();' if no_reload else f"""(function(){{
2147
- 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;
2148
2177
  var _t4_interval=parseInt('{poll_interval_ms}')||3000;
2178
+ var _t4_ws=null,_t4_poll_timer=null,_t4_mtime=null;
2149
2179
  function _t4_apply(d){{
2150
- var f=d.file||'';
2151
- 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)}});
2152
2183
  if(isCss){{
2153
2184
  var links=document.querySelectorAll('link[rel="stylesheet"]');
2154
2185
  links.forEach(function(l){{
2155
2186
  var href=l.getAttribute('href');
2156
- 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()))}}
2157
2188
  }});
2158
2189
  }}else{{
2159
2190
  location.reload();
@@ -2161,15 +2192,39 @@ def render_dev_toolbar(method: str, path: str, matched_pattern: str,
2161
2192
  }}
2162
2193
  function _t4_poll(){{
2163
2194
  fetch('/__dev/api/mtime').then(function(r){{return r.json()}}).then(function(d){{
2164
- if(!_t4_mtime){{_t4_mtime=d.mtime;return;}}
2165
- if(d.mtime>_t4_mtime){{
2195
+ if(_t4_mtime===null){{_t4_mtime=d.mtime;return;}}
2196
+ if(d.mtime!==_t4_mtime){{
2166
2197
  _t4_mtime=d.mtime;
2167
2198
  if(_t4_debounce)clearTimeout(_t4_debounce);
2168
2199
  _t4_debounce=setTimeout(function(){{_t4_apply(d);}},500);
2169
2200
  }}
2170
2201
  }}).catch(function(){{}});
2171
2202
  }}
2172
- 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();
2173
2228
  }})();"""}
2174
2229
  function tina4VersionModal(){{
2175
2230
  var m=document.getElementById('tina4-ver-modal');
@@ -2861,6 +2916,46 @@ async def _api_mcp_call(request, response):
2861
2916
  return response({"ok": False, "name": name, "error": str(exc)}, 500)
2862
2917
 
2863
2918
 
2919
+ # ─── MCP JSON-RPC + SSE endpoint ───────────────────────────────────
2920
+ #
2921
+ # The protocol surface real MCP clients (Claude Desktop/Code) speak —
2922
+ # JSON-RPC 2.0 over the HTTP+SSE transport. Mounted on the running dev
2923
+ # server so each `tina4 serve`d project exposes its OWN endpoint, giving
2924
+ # an AI agent live access scoped to that project. Shares the same
2925
+ # `_default_server` tool registry as the REST shim above, so every
2926
+ # @mcp_tool shows up on both surfaces.
2927
+
2928
+ async def _api_mcp_rpc(request, response):
2929
+ """POST — the JSON-RPC endpoint. Mounted at /__dev/mcp and
2930
+ /__dev/mcp/message. Forwards to the default MCP server's
2931
+ handle_message() and returns the JSON-RPC response; notifications
2932
+ (no id) yield an empty 204.
2933
+ """
2934
+ import json as _json
2935
+ from tina4_python.mcp import _get_default_server, is_enabled
2936
+ if not is_enabled():
2937
+ return response({"error": "MCP disabled"}, 404)
2938
+ server = _get_default_server()
2939
+ body = request.body
2940
+ raw = body if isinstance(body, (dict, str)) else str(body)
2941
+ result = server.handle_message(raw)
2942
+ if not result:
2943
+ return response("", 204)
2944
+ return response(_json.loads(result))
2945
+
2946
+
2947
+ async def _api_mcp_sse(request, response):
2948
+ """GET — SSE handshake. Emits the `endpoint` event telling the client
2949
+ where to POST JSON-RPC messages, per the MCP HTTP+SSE transport.
2950
+ """
2951
+ from tina4_python.mcp import is_enabled
2952
+ if not is_enabled():
2953
+ return response({"error": "MCP disabled"}, 404)
2954
+ base = request.path.rsplit("/sse", 1)[0]
2955
+ sse = f"event: endpoint\ndata: {base}/message\n\n"
2956
+ return response(sse, 200, "text/event-stream")
2957
+
2958
+
2864
2959
  # ─── Scaffold REST shim ────────────────────────────────────────────
2865
2960
  #
2866
2961
  # Wraps the tina4python `generate <kind> <name>` CLI commands so the
@@ -259,7 +259,7 @@ def register_dev_tools(server):
259
259
  """List all registered routes with methods and paths."""
260
260
  from tina4_python.core.router import Router
261
261
  routes = []
262
- for route in Router._routes:
262
+ for route in Router.get_routes():
263
263
  routes.append({
264
264
  "method": route.get("method", ""),
265
265
  "path": route.get("path", ""),