tina4-python 3.12.10__tar.gz → 3.12.13__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.12.10 → tina4_python-3.12.13}/PKG-INFO +3 -3
  2. {tina4_python-3.12.10 → tina4_python-3.12.13}/README.md +2 -2
  3. {tina4_python-3.12.10 → tina4_python-3.12.13}/pyproject.toml +1 -1
  4. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/CLAUDE.md +6 -6
  5. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/__init__.py +1 -1
  6. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/cli/__init__.py +6 -4
  7. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/request.py +20 -1
  8. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/router.py +93 -3
  9. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/server.py +156 -7
  10. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/debug/error_overlay.py +36 -3
  11. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dev_admin/__init__.py +351 -127
  12. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dev_admin/plan.py +39 -12
  13. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/mcp/tools.py +246 -11
  14. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/migration/runner.py +192 -67
  15. tina4_python-3.12.13/tina4_python/public/__feedback/widget.js +96 -0
  16. tina4_python-3.12.13/tina4_python/public/js/tina4-dev-admin.js +1089 -0
  17. tina4_python-3.12.13/tina4_python/public/js/tina4-dev-admin.min.js +1089 -0
  18. tina4_python-3.12.10/tina4_python/public/js/tina4-dev-admin.js +0 -1413
  19. tina4_python-3.12.10/tina4_python/public/js/tina4-dev-admin.min.js +0 -1413
  20. {tina4_python-3.12.10 → tina4_python-3.12.13}/.gitignore +0 -0
  21. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/HtmlElement.py +0 -0
  22. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/Testing.py +0 -0
  23. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/ai/__init__.py +0 -0
  24. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/api/__init__.py +0 -0
  25. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/auth/__init__.py +0 -0
  26. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/cache/__init__.py +0 -0
  27. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/container/__init__.py +0 -0
  28. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/__init__.py +0 -0
  29. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/cache.py +0 -0
  30. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/constants.py +0 -0
  31. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/events.py +0 -0
  32. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/middleware.py +0 -0
  33. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/rate_limiter.py +0 -0
  34. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/response.py +0 -0
  35. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/crud/__init__.py +0 -0
  36. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/__init__.py +0 -0
  37. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/adapter.py +0 -0
  38. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/connection.py +0 -0
  39. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/firebird.py +0 -0
  40. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/mongodb.py +0 -0
  41. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/mssql.py +0 -0
  42. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/mysql.py +0 -0
  43. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/odbc.py +0 -0
  44. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/postgres.py +0 -0
  45. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/sqlite.py +0 -0
  46. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/debug/__init__.py +0 -0
  47. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dev_admin/metrics.py +0 -0
  48. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dev_admin/project_index.py +0 -0
  49. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/docs.py +0 -0
  50. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dotenv/__init__.py +0 -0
  51. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/frond/FROND.md +0 -0
  52. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/frond/__init__.py +0 -0
  53. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/frond/engine.py +0 -0
  54. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/auth/meta.json +0 -0
  55. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  56. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/database/meta.json +0 -0
  57. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  58. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/error-overlay/meta.json +0 -0
  59. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  60. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/orm/meta.json +0 -0
  61. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  62. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  63. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/queue/meta.json +0 -0
  64. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  65. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/rest-api/meta.json +0 -0
  66. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  67. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/templates/meta.json +0 -0
  68. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  69. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  70. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/graphql/__init__.py +0 -0
  71. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/i18n/__init__.py +0 -0
  72. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/mcp/__init__.py +0 -0
  73. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/mcp/protocol.py +0 -0
  74. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/messenger/__init__.py +0 -0
  75. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/migration/__init__.py +0 -0
  76. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/orm/__init__.py +0 -0
  77. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/orm/fields.py +0 -0
  78. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/orm/model.py +0 -0
  79. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/css/tina4.css +0 -0
  80. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/css/tina4.min.css +0 -0
  81. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/favicon.ico +0 -0
  82. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/images/logo.svg +0 -0
  83. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  84. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/js/frond.js +0 -0
  85. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/js/frond.min.js +0 -0
  86. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/js/tina4.min.js +0 -0
  87. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/js/tina4js.min.js +0 -0
  88. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/swagger/index.html +0 -0
  89. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  90. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/query_builder/__init__.py +0 -0
  91. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/__init__.py +0 -0
  92. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/job.py +0 -0
  93. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/kafka_backend.py +0 -0
  94. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/lite_backend.py +0 -0
  95. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/mongo_backend.py +0 -0
  96. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/rabbitmq_backend.py +0 -0
  97. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue_backends/__init__.py +0 -0
  98. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue_backends/kafka_backend.py +0 -0
  99. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue_backends/mongo_backend.py +0 -0
  100. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  101. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/__init__.py +0 -0
  102. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  103. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_badges.scss +0 -0
  104. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  105. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_cards.scss +0 -0
  106. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_forms.scss +0 -0
  107. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_grid.scss +0 -0
  108. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_modals.scss +0 -0
  109. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_nav.scss +0 -0
  110. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_reset.scss +0 -0
  111. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_tables.scss +0 -0
  112. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_typography.scss +0 -0
  113. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  114. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_variables.scss +0 -0
  115. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/base.scss +0 -0
  116. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/colors.scss +0 -0
  117. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/tina4.scss +0 -0
  118. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/seeder/__init__.py +0 -0
  119. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/service/__init__.py +0 -0
  120. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session/__init__.py +0 -0
  121. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session_handlers/__init__.py +0 -0
  122. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  123. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session_handlers/redis_handler.py +0 -0
  124. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session_handlers/valkey_handler.py +0 -0
  125. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/swagger/__init__.py +0 -0
  126. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/components/crud.twig +0 -0
  127. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  128. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  129. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/docker/python/Dockerfile +0 -0
  130. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  131. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/302.twig +0 -0
  132. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/401.twig +0 -0
  133. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/403.twig +0 -0
  134. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/404.twig +0 -0
  135. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/500.twig +0 -0
  136. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/502.twig +0 -0
  137. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/503.twig +0 -0
  138. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/base.twig +0 -0
  139. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/frontend/README.md +0 -0
  140. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/readme.md +0 -0
  141. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.12.10 → tina4_python-3.12.13}/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.12.10
3
+ Version: 3.12.13
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
@@ -208,7 +208,7 @@ Visit `http://localhost:7146/api/hello` -- routes are auto-discovered, no import
208
208
  Edit `.env`:
209
209
 
210
210
  ```bash
211
- DATABASE_URL=sqlite:///data/app.db
211
+ TINA4_DATABASE_URL=sqlite:///data/app.db
212
212
  ```
213
213
 
214
214
  Create and run a migration:
@@ -695,7 +695,7 @@ Frond.clear_cache()
695
695
 
696
696
  ```bash
697
697
  SECRET=your-jwt-secret
698
- DATABASE_URL=sqlite:///data/app.db
698
+ TINA4_DATABASE_URL=sqlite:///data/app.db
699
699
  TINA4_DEBUG=true # Enable dev toolbar, error overlay
700
700
  TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
701
701
  TINA4_LOCALE=en # en, fr, af, zh, ja, es
@@ -176,7 +176,7 @@ Visit `http://localhost:7146/api/hello` -- routes are auto-discovered, no import
176
176
  Edit `.env`:
177
177
 
178
178
  ```bash
179
- DATABASE_URL=sqlite:///data/app.db
179
+ TINA4_DATABASE_URL=sqlite:///data/app.db
180
180
  ```
181
181
 
182
182
  Create and run a migration:
@@ -663,7 +663,7 @@ Frond.clear_cache()
663
663
 
664
664
  ```bash
665
665
  SECRET=your-jwt-secret
666
- DATABASE_URL=sqlite:///data/app.db
666
+ TINA4_DATABASE_URL=sqlite:///data/app.db
667
667
  TINA4_DEBUG=true # Enable dev toolbar, error overlay
668
668
  TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
669
669
  TINA4_LOCALE=en # en, fr, af, zh, ja, es
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.12.10"
3
+ version = "3.12.13"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -287,7 +287,7 @@ Queue(topic="tasks").push({"action": "send_email"})
287
287
  - Other methods: `.to_json()`, `.to_array()`, `.to_csv()`, `.to_paginate()`
288
288
  4. **fetch_one()**: Returns a plain dict (or None), NOT a DatabaseResult
289
289
  5. **Dict access**: All query results use dict access `row["column"]` not attribute access `row.column`
290
- 6. **Connection strings**: v3 uses standard URL format: `driver://host:port/database` with separate `username` and `password` parameters. Example: `Database("firebird://localhost:3050//path/to/db", "SYSDBA", "masterkey")`. Environment variable: `DATABASE_URL`.
290
+ 6. **Connection strings**: v3 uses standard URL format: `driver://host:port/database` with separate `username` and `password` parameters. Example: `Database("firebird://localhost:3050//path/to/db", "SYSDBA", "masterkey")`. Environment variable: `TINA4_DATABASE_URL`.
291
291
  7. **Running the app**: `uv run python app.py <port> <name>` — port and name are CLI args handled by tina4_python
292
292
  8. **SCSS**: Files in `src/scss/` are auto-compiled to `src/public/css/` on startup
293
293
  12. **Background tasks**: Use `background(fn, interval)` from `tina4_python.core.server` — never use `threading.Thread` for periodic work. The `background()` function runs tasks cooperatively in the asyncio event loop with proper shutdown handling.
@@ -408,7 +408,7 @@ Set `TINA4_DEBUG=true` in `.env` to enable development features:
408
408
  - **CSS hot-reload** — SCSS/CSS changes refresh stylesheets without full page reload
409
409
  - **SCSS auto-compile** — `.scss` files in `src/scss/` are compiled to `src/public/css/` on save
410
410
  - **Error overlay** — Runtime errors display a rich, syntax-highlighted overlay in the browser
411
- - **Hot-patching** — Python code changes are live-patched via jurigged (no server restart)
411
+ - **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
412
 
413
413
  DevReload connects via WebSocket at `/__dev_reload`. No configuration needed.
414
414
 
@@ -741,7 +741,7 @@ db = Database("postgresql://localhost:5432/mydb", "user", "password") # Post
741
741
  db = Database("mysql://localhost:3306/mydb", "user", "password") # MySQL
742
742
  db = Database("firebird://localhost:3050//path/to/db", "SYSDBA", "masterkey") # Firebird
743
743
  db = Database("mssql://localhost:1433/mydb", "sa", "password") # MSSQL
744
- db = Database() # Uses DATABASE_URL env var
744
+ db = Database() # Uses TINA4_DATABASE_URL env var
745
745
  ```
746
746
 
747
747
  ### MongoDB support
@@ -1565,9 +1565,9 @@ TINA4_API_KEY=your-api-key # Static bearer token for API auth (API_KEY fa
1565
1565
  TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
1566
1566
 
1567
1567
  # Database
1568
- DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
1569
- DATABASE_USERNAME= # DB username (for PostgreSQL, MySQL, etc.)
1570
- DATABASE_PASSWORD= # DB password
1568
+ TINA4_DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
1569
+ TINA4_DATABASE_USERNAME= # DB username (for PostgreSQL, MySQL, etc.)
1570
+ TINA4_DATABASE_PASSWORD= # DB password
1571
1571
 
1572
1572
  # Framework
1573
1573
  TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
@@ -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.12.10"
11
+ __version__ = "3.12.13"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -258,7 +258,7 @@ def _console(args=None):
258
258
  from tina4_python.api import Api
259
259
  from tina4_python.core.events import on, emit
260
260
 
261
- # Try to connect database from DATABASE_URL
261
+ # Try to connect database from TINA4_DATABASE_URL
262
262
  db = None
263
263
  db_url = os.environ.get("TINA4_DATABASE_URL")
264
264
  if db_url:
@@ -345,15 +345,17 @@ def _init(args):
345
345
  encoding="utf-8",
346
346
  )
347
347
 
348
- # Create .env
348
+ # Create .env — every framework env var is TINA4_-prefixed since v3.12.
349
+ # The boot guard refuses to start with bare DATABASE_URL / SECRET set,
350
+ # so a fresh project MUST get the prefixed names.
349
351
  env_file = target / ".env"
350
352
  if not env_file.exists():
351
353
  env_file.write_text(
352
354
  "# Tina4 Configuration\n"
353
355
  "TINA4_DEBUG=true\n"
354
356
  "TINA4_LOG_LEVEL=ALL\n"
355
- "DATABASE_URL=sqlite:///data/app.db\n"
356
- 'SECRET=change-me-in-production\n',
357
+ "TINA4_DATABASE_URL=sqlite:///data/app.db\n"
358
+ 'TINA4_SECRET=change-me-in-production\n',
357
359
  encoding="utf-8",
358
360
  )
359
361
 
@@ -19,7 +19,7 @@ class Request:
19
19
  """Parsed HTTP request — everything a route handler needs."""
20
20
 
21
21
  __slots__ = (
22
- "method", "path", "query_string", "params", "query", "headers",
22
+ "method", "path", "url", "query_string", "params", "query", "headers",
23
23
  "body", "raw_body", "cookies", "files", "ip",
24
24
  "content_type", "session", "_route_params",
25
25
  )
@@ -27,6 +27,7 @@ class Request:
27
27
  def __init__(self):
28
28
  self.method: str = "GET"
29
29
  self.path: str = "/"
30
+ self.url: str = "/"
30
31
  self.query_string: str = ""
31
32
  self.params: dict = {} # Query string + route params merged
32
33
  self.query: dict = {} # Query string params only (separate from route params)
@@ -56,6 +57,24 @@ class Request:
56
57
  req.content_type = req.headers.get("content-type", "")
57
58
  req.ip = _extract_ip(scope, req.headers)
58
59
 
60
+ # Reconstruct the full absolute URL — scheme://host[:port]/path[?query].
61
+ # Honours x-forwarded-proto and x-forwarded-host so apps behind a proxy
62
+ # still see the URL the client used. Matches PHP/Ruby/Node parity.
63
+ scheme = (
64
+ req.headers.get("x-forwarded-proto")
65
+ or scope.get("scheme")
66
+ or "http"
67
+ )
68
+ host = (
69
+ req.headers.get("x-forwarded-host")
70
+ or req.headers.get("host")
71
+ or "localhost"
72
+ )
73
+ url = f"{scheme}://{host}{req.path}"
74
+ if req.query_string:
75
+ url = f"{url}?{req.query_string}"
76
+ req.url = url
77
+
59
78
  # Check upload size limit
60
79
  content_length = int(req.headers.get("content-length", 0) or 0)
61
80
  if content_length > TINA4_MAX_UPLOAD_SIZE or len(body) > TINA4_MAX_UPLOAD_SIZE:
@@ -246,6 +246,33 @@ class Router:
246
246
  """Register a DELETE route (imperative, non-decorator style)."""
247
247
  return cls.add("DELETE", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
248
248
 
249
+ @classmethod
250
+ def head(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
251
+ """Register an explicit HEAD route.
252
+
253
+ By default the framework auto-handles HEAD by falling back to the GET
254
+ route and stripping the body (RFC 9110 §9.3.2). Use this method only
255
+ when you need a HEAD handler that does something different from GET —
256
+ e.g. cheaper existence-check logic, custom validator headers without
257
+ the cost of building the body.
258
+
259
+ The framework still strips the response body for you on the way out —
260
+ HEAD MUST NOT return content, even if your handler does, so we
261
+ enforce that unconditionally rather than relying on developer care.
262
+ """
263
+ return cls.add("HEAD", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
264
+
265
+ @classmethod
266
+ def options(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
267
+ """Register an explicit OPTIONS route.
268
+
269
+ By default the framework auto-handles OPTIONS by building an Allow
270
+ header from every method registered for the path and returning 204
271
+ (RFC 9110 §9.3.7). Use this method to take over that behaviour —
272
+ e.g. to return a richer OPTIONS payload describing the resource.
273
+ """
274
+ return cls.add("OPTIONS", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
275
+
249
276
  @classmethod
250
277
  def any(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
251
278
  """Register a route for any HTTP method (imperative, non-decorator style)."""
@@ -291,7 +318,11 @@ class Router:
291
318
  # Route has custom middleware — developer handles auth themselves
292
319
  auth_required = False
293
320
  else:
294
- auth_required = m not in ("GET", "ANY")
321
+ # GET, HEAD, OPTIONS, and ANY are public by default. HEAD and
322
+ # OPTIONS are safe/idempotent introspection methods (RFC 9110
323
+ # §9.2.1) — requiring auth on them breaks cache validators
324
+ # and CORS preflight probes.
325
+ auth_required = m not in ("GET", "HEAD", "OPTIONS", "ANY")
295
326
 
296
327
  route = {
297
328
  "method": m,
@@ -312,9 +343,18 @@ class Router:
312
343
 
313
344
  @staticmethod
314
345
  def match(method: str, path: str) -> tuple[dict | None, dict]:
315
- """Find a route matching method + path. Returns (route, params)."""
346
+ """Find a route matching method + path. Returns (route, params).
347
+
348
+ RFC 9110 §9.3.2: HEAD is identical to GET except the response carries
349
+ no body. If the app didn't register a dedicated HEAD route, we
350
+ transparently match the GET route; the dispatcher strips the body on
351
+ the way out, so the handler doesn't need to know HEAD even happened.
352
+ """
353
+ method_upper = method.upper()
354
+
355
+ # First pass: exact method match (covers HEAD → explicit HEAD route too)
316
356
  for route in _routes:
317
- if route["method"] not in (method.upper(), "ANY"):
357
+ if route["method"] not in (method_upper, "ANY"):
318
358
  continue
319
359
  m = route["pattern"].match(path)
320
360
  if m:
@@ -323,8 +363,58 @@ class Router:
323
363
  params[name] = m.group(i + 1)
324
364
  return route, params
325
365
 
366
+ # Second pass: HEAD auto-fallback to GET when no HEAD route registered
367
+ if method_upper == "HEAD":
368
+ for route in _routes:
369
+ if route["method"] not in ("GET", "ANY"):
370
+ continue
371
+ m = route["pattern"].match(path)
372
+ if m:
373
+ params = {}
374
+ for i, name in enumerate(route["param_names"]):
375
+ params[name] = m.group(i + 1)
376
+ return route, params
377
+
326
378
  return None, {}
327
379
 
380
+ @staticmethod
381
+ def methods_allowed_for_path(path: str) -> list[str]:
382
+ """Return the list of HTTP methods registered for ``path``, in the
383
+ order GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS. Used by
384
+ the dispatcher to build the ``Allow:`` header on 405 / OPTIONS
385
+ responses (RFC 9110 §10.2.1, §9.3.7).
386
+
387
+ If GET is registered, HEAD is appended implicitly (the framework
388
+ auto-falls-back HEAD to GET). OPTIONS is appended whenever the
389
+ path has any registered method (the framework auto-handles OPTIONS).
390
+ """
391
+ # ANY routes count for every method but we don't enumerate them
392
+ # individually — flag whether ANY matched and union it with the
393
+ # concrete-method matches.
394
+ method_order = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
395
+ seen: set[str] = set()
396
+ any_matched = False
397
+
398
+ for route in _routes:
399
+ if not route["pattern"].match(path):
400
+ continue
401
+ m = route["method"]
402
+ if m == "ANY":
403
+ any_matched = True
404
+ elif m in method_order:
405
+ seen.add(m)
406
+
407
+ if any_matched:
408
+ seen.update(method_order)
409
+
410
+ # GET implies HEAD; any registered method implies OPTIONS.
411
+ if seen:
412
+ if "GET" in seen:
413
+ seen.add("HEAD")
414
+ seen.add("OPTIONS")
415
+
416
+ return [m for m in method_order if m in seen]
417
+
328
418
  @staticmethod
329
419
  def get_routes() -> list[dict]:
330
420
  """Return all registered routes."""
@@ -49,19 +49,45 @@ def background(callback, interval: float = 1.0):
49
49
 
50
50
 
51
51
  def _auto_discover(root_dir: str = "src"):
52
- """Auto-import all .py files in src/ to trigger route decorators."""
52
+ """Auto-import all .py files in ``root_dir`` to trigger route decorators.
53
+
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.
57
+
58
+ Import failures are recorded to ``data/.broken/`` so /health surfaces them
59
+ instead of swallowing them into a console line nobody reads.
60
+ """
53
61
  root = Path(root_dir).resolve()
54
62
  if not root.is_dir():
55
63
  return
56
64
 
65
+ # Folders to skip — non-Python sub-trees inside src/.
57
66
  skip = {"public", "templates", "scss", "locales", "icons"}
67
+ # Routes folder is special-cased so the user gets a clear warning when
68
+ # it exists but contains zero discoverable Python files.
69
+ routes_dir = root / "routes"
70
+ found_route_files = 0
58
71
 
59
72
  for py_file in sorted(root.rglob("*.py")):
60
- if any(part.startswith("_") for part in py_file.parts):
73
+ # Only filter on parts INSIDE src/, not the absolute path. Previously
74
+ # `py_file.parts` included every ancestor, so a project living under
75
+ # something like /Users/me/_archive/myapp would silently skip every
76
+ # file. Compute the relative parts first and filter on those.
77
+ try:
78
+ rel_to_root = py_file.relative_to(root)
79
+ except ValueError:
80
+ continue
81
+
82
+ rel_parts = rel_to_root.parts
83
+ if any(part.startswith("_") for part in rel_parts):
61
84
  continue
62
- if any(s in py_file.parts for s in skip):
85
+ if any(s in rel_parts for s in skip):
63
86
  continue
64
87
 
88
+ if routes_dir in py_file.parents:
89
+ found_route_files += 1
90
+
65
91
  try:
66
92
  rel = py_file.relative_to(Path.cwd()).with_suffix("")
67
93
  module_name = ".".join(rel.parts)
@@ -70,6 +96,39 @@ def _auto_discover(root_dir: str = "src"):
70
96
  Log.debug(f"Loaded: {module_name}")
71
97
  except Exception as e:
72
98
  Log.error(f"Failed to load {py_file}: {e}")
99
+ _record_broken_import(py_file, e)
100
+
101
+ # User-friendly hint: routes folder has Python files but the router is
102
+ # still empty. They almost certainly forgot the @get/@post decorator.
103
+ if found_route_files > 0:
104
+ try:
105
+ from tina4_python.core.router import Router
106
+ if not Router.get_routes():
107
+ Log.warning(
108
+ f"Auto-discover found {found_route_files} .py file(s) in "
109
+ f"{routes_dir} but no routes registered. Did you forget "
110
+ f"@get / @post / @put / @delete on your handler?"
111
+ )
112
+ except Exception:
113
+ pass
114
+
115
+
116
+ def _record_broken_import(py_file: Path, error: Exception) -> None:
117
+ """Write a .broken sentinel so /health and the dev dashboard surface
118
+ auto-discover failures instead of burying them in the console."""
119
+ try:
120
+ broken_dir = Path("data/.broken")
121
+ broken_dir.mkdir(parents=True, exist_ok=True)
122
+ import json
123
+ slug = str(py_file).replace("/", "_").replace("\\", "_")
124
+ (broken_dir / f"discover_{slug}.broken").write_text(json.dumps({
125
+ "type": "auto_discover_failure",
126
+ "file": str(py_file),
127
+ "error": f"{type(error).__name__}: {error}",
128
+ }))
129
+ except Exception:
130
+ # If even the .broken write fails we already logged the original error.
131
+ pass
73
132
 
74
133
 
75
134
  def _ensure_folders():
@@ -932,10 +991,35 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
932
991
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head>
933
992
  <body><div id="app" data-framework="python" data-color="#3b82f6"></div>
934
993
  <script src="/js/tina4-dev-admin.min.js"></script></body></html>""")
994
+ # Never cache the dev admin shell — otherwise old HTML can
995
+ # keep loading stale widget references / outdated bundle URLs
996
+ # after we ship UX changes, leaving the user staring at
997
+ # phantoms that no longer exist server-side.
998
+ response.header("cache-control", "no-store, must-revalidate")
999
+ response.header("pragma", "no-cache")
935
1000
  else:
936
1001
  handlers = get_api_handlers()
937
1002
  handler_info = handlers.get(request.path)
938
- if handler_info and request.method == handler_info[0]:
1003
+ if not handler_info:
1004
+ # Fallback: longest-prefix wildcard match. Routes registered
1005
+ # with a trailing "/*" (e.g. "/__dev/api/threads/*") catch
1006
+ # everything under that namespace — used for parameterised
1007
+ # resources like /threads/{id} that don't fit exact-match.
1008
+ best_prefix = ""
1009
+ for key, info in handlers.items():
1010
+ if key.endswith("/*"):
1011
+ prefix = key[:-1] # keep the trailing slash
1012
+ if request.path.startswith(prefix) and len(prefix) > len(best_prefix):
1013
+ best_prefix = prefix
1014
+ handler_info = info
1015
+ # Allow "*" as a method wildcard for handlers that switch on
1016
+ # request.method themselves (REST resources with GET+POST+PATCH
1017
+ # on the same path).
1018
+ method_ok = (
1019
+ handler_info is not None
1020
+ and (handler_info[0] == "*" or request.method == handler_info[0])
1021
+ )
1022
+ if method_ok:
939
1023
  try:
940
1024
  def _resp(data, code=200, content_type=None):
941
1025
  # content_type overrides the auto-detected MIME —
@@ -955,6 +1039,14 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
955
1039
  response.status(code).json(data)
956
1040
  return data
957
1041
  _resp.render = response.render
1042
+ # Expose .stream() so handlers can return an SSE/chunked
1043
+ # response — used by the dev_admin supervisor proxy to
1044
+ # forward the agent server's text/event-stream live
1045
+ # (instead of buffering the whole multi-agent run).
1046
+ _resp.stream = response.stream
1047
+ # Expose .header() so handlers can set custom headers
1048
+ # (e.g. Cache-Control on the feedback widget bundle).
1049
+ _resp.header = response.header
958
1050
  import inspect
959
1051
  _tsig = inspect.signature(handler_info[1])
960
1052
  _tpcount = len(_tsig.parameters)
@@ -1306,12 +1398,17 @@ async def handle(request: Request) -> Response:
1306
1398
  canonical = request.path.rstrip("/") or "/"
1307
1399
  return response.status(301).header("location", canonical)
1308
1400
 
1309
- # Dev admin — also catches /ai/api/chat (SPA's ollama proxy) and the
1401
+ # Dev admin — also catches /ai/api/chat (SPA's ollama proxy), the
1310
1402
  # bare /ai /vision /embed /image /rag service-health probes that
1311
- # drive the "SERVICES ●●●●●" dots in the dev-admin UI.
1403
+ # drive the "SERVICES ●●●●●" dots in the dev-admin UI, and the
1404
+ # /__feedback/* routes used by the customer feedback widget. The
1405
+ # feedback paths live OUTSIDE /__dev because the widget is for
1406
+ # whitelisted END USERS of the shipped app — they shouldn't see
1407
+ # any /__dev URL in their network tab.
1312
1408
  _dev_extra_paths = {"/ai/api/chat", "/ai", "/vision", "/embed", "/image", "/rag"}
1313
1409
  if _is_dev and (
1314
1410
  request.path.startswith("/__dev")
1411
+ or request.path.startswith("/__feedback")
1315
1412
  or request.path in _dev_extra_paths
1316
1413
  ):
1317
1414
  return await _handle_dev_admin(request, response)
@@ -1338,7 +1435,43 @@ async def handle(request: Request) -> Response:
1338
1435
  except Exception as e:
1339
1436
  response = _handle_route_error(e, request, response, request_id, _is_dev)
1340
1437
  else:
1341
- response = _handle_no_route(request, response, request_id)
1438
+ # RFC 9110 conformance — before falling through to 404 / static / template,
1439
+ # check whether the PATH is known to the router under any OTHER method.
1440
+ # If yes:
1441
+ # - OPTIONS request → 204 No Content with Allow listing the methods
1442
+ # (RFC 9110 §9.3.7). Generic OPTIONS handler.
1443
+ # - Any other method (PUT on GET-only route, TRACE, CONNECT, etc.)
1444
+ # → 405 Method Not Allowed with Allow header (§15.5.6 + §10.2.1).
1445
+ allowed = Router.methods_allowed_for_path(request.path)
1446
+ if allowed:
1447
+ allow_header = ", ".join(allowed)
1448
+ if request.method.upper() == "OPTIONS":
1449
+ response.header("Allow", allow_header)
1450
+ response.status(204)
1451
+ else:
1452
+ response.header("Allow", allow_header)
1453
+ response.status(405).json({
1454
+ "error": "Method Not Allowed",
1455
+ "path": request.path,
1456
+ "method": request.method,
1457
+ "allow": allowed,
1458
+ "status": 405,
1459
+ })
1460
+ else:
1461
+ response = _handle_no_route(request, response, request_id)
1462
+
1463
+ # RFC 9110 §9.3.2: a HEAD response MUST NOT include content. Strip the
1464
+ # body unconditionally (even for explicit Router.head() handlers that
1465
+ # accidentally returned one) and record what Content-Length the GET
1466
+ # would have sent — cache validators / link checkers / monitoring
1467
+ # probes rely on that header to size estimates.
1468
+ if request.method.upper() == "HEAD":
1469
+ body = response.content if response.content is not None else b""
1470
+ if isinstance(body, str):
1471
+ body = body.encode("utf-8")
1472
+ if body:
1473
+ response.header("Content-Length", str(len(body)))
1474
+ response.content = b""
1342
1475
 
1343
1476
  return _finalize_response(request, response, route, request_id, _is_dev, _req_start)
1344
1477
 
@@ -1407,6 +1540,22 @@ async def app(scope: dict, receive, send):
1407
1540
  await send({"type": "http.response.body", "body": b"", "more_body": False})
1408
1541
  return
1409
1542
 
1543
+ # Customer feedback widget injection — adds <script src="/__feedback/widget.js">
1544
+ # to HTML responses for whitelisted users. No-op if the feature is
1545
+ # off (TINA4_FEEDBACK_WHITELIST empty) or the user isn't whitelisted
1546
+ # or the body isn't HTML. Done BEFORE ETag/header build so the
1547
+ # injected bytes are included in the ETag hash + Content-Length.
1548
+ try:
1549
+ if (
1550
+ response.content
1551
+ and isinstance(response.content, (bytes, bytearray))
1552
+ and "text/html" in (response.content_type or "").lower()
1553
+ ):
1554
+ from tina4_python.dev_admin import inject_feedback_widget
1555
+ response.content = inject_feedback_widget(request, bytes(response.content))
1556
+ except Exception:
1557
+ pass # Injection is best-effort — never break the response.
1558
+
1410
1559
  # ETag check — 304 Not Modified
1411
1560
  if_none_match = request.headers.get("if-none-match", "")
1412
1561
  accept_encoding = request.headers.get("accept-encoding", "")
@@ -79,9 +79,31 @@ def _format_source_block(filename: str, lineno: int) -> str:
79
79
  )
80
80
 
81
81
 
82
- def _format_frame(frame: traceback.FrameSummary) -> str:
83
- """Render one stack frame."""
82
+ def _format_frame(frame: traceback.FrameSummary, captured_at: float = 0.0) -> str:
83
+ """Render one stack frame.
84
+
85
+ When the file was modified AFTER `captured_at`, append a
86
+ "(file modified since)" badge so a stale browser-cached overlay
87
+ can't lie about what the source looks like now. The AI coder
88
+ often rewrites files in place between page loads, leaving the
89
+ overlay's source view showing different code than what raised
90
+ the error.
91
+ """
84
92
  source = _format_source_block(frame.filename, frame.lineno) if frame.filename and frame.lineno else ""
93
+ stale_badge = ""
94
+ if captured_at and frame.filename:
95
+ try:
96
+ mtime = os.path.getmtime(frame.filename)
97
+ if mtime > captured_at + 0.5: # 0.5s margin for fs noise
98
+ from datetime import datetime, timezone
99
+ mtime_iso = datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%H:%M:%S")
100
+ stale_badge = (
101
+ f' <span style="background:{_PEACH};color:{_BG};padding:1px 8px;'
102
+ f'border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;">'
103
+ f'FILE MODIFIED @ {mtime_iso} — source may not match what failed</span>'
104
+ )
105
+ except OSError:
106
+ pass
85
107
  return (
86
108
  f'<div style="margin-bottom:16px;">'
87
109
  f'<div style="margin-bottom:4px;">'
@@ -90,6 +112,7 @@ def _format_frame(frame: traceback.FrameSummary) -> str:
90
112
  f'<span style="color:{_YELLOW};">{frame.lineno}</span>'
91
113
  f'<span style="color:{_SUBTEXT};"> in </span>'
92
114
  f'<span style="color:{_GREEN};">{_escape(frame.name)}</span>'
115
+ f"{stale_badge}"
93
116
  f"</div>"
94
117
  f"{source}"
95
118
  f"</div>"
@@ -131,14 +154,23 @@ def render_error_overlay(exception: BaseException, request: Any = None) -> str:
131
154
  Returns:
132
155
  A complete HTML page string.
133
156
  """
157
+ import time as _time
158
+ captured_at = _time.time()
159
+ captured_iso = _time.strftime("%H:%M:%S UTC", _time.gmtime(captured_at))
160
+
134
161
  exc_type = type(exception).__qualname__
135
162
  exc_msg = str(exception)
136
163
  tb = traceback.extract_tb(exception.__traceback__)
137
164
 
138
165
  # ── Stack trace ──
166
+ # Each frame compares its source file's mtime to captured_at and
167
+ # flags itself if the file has been modified since — protects
168
+ # against the "browser cached an old overlay, then the AI rewrote
169
+ # the file" confusion where displayed source no longer matches
170
+ # what actually raised the error.
139
171
  frames_html = ""
140
172
  for frame in reversed(tb):
141
- frames_html += _format_frame(frame)
173
+ frames_html += _format_frame(frame, captured_at=captured_at)
142
174
 
143
175
  # ── Request info ──
144
176
  request_pairs: list[tuple[str, str]] = []
@@ -192,6 +224,7 @@ body{{background:{_BG};color:{_TEXT};font-family:-apple-system,BlinkMacSystemFon
192
224
  <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
193
225
  <span style="background:{_RED};color:{_BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
194
226
  <span style="color:{_SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
227
+ <span style="color:{_SUBTEXT};font-size:12px;margin-left:auto;font-family:'SF Mono',Menlo,monospace;">captured {captured_iso}</span>
195
228
  </div>
196
229
  <h1 style="color:{_RED};font-size:28px;font-weight:700;margin-bottom:8px;">{_escape(exc_type)}</h1>
197
230
  <p style="color:{_TEXT};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:{_SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid {_RED};">{_escape(exc_msg)}</p>