tina4-python 3.13.104__tar.gz → 3.13.106__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 (162) hide show
  1. {tina4_python-3.13.104 → tina4_python-3.13.106}/PKG-INFO +1 -1
  2. {tina4_python-3.13.104 → tina4_python-3.13.106}/pyproject.toml +1 -1
  3. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/__init__.py +6 -1
  4. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/cli/__init__.py +2 -7
  5. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/__init__.py +2 -2
  6. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/router.py +28 -5
  7. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/server.py +88 -0
  8. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/orm/model.py +17 -5
  9. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue/__init__.py +42 -7
  10. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue/lite_backend.py +16 -2
  11. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue/mongo_backend.py +67 -9
  12. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/websocket/__init__.py +33 -0
  13. {tina4_python-3.13.104 → tina4_python-3.13.106}/.gitignore +0 -0
  14. {tina4_python-3.13.104 → tina4_python-3.13.106}/README.md +0 -0
  15. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/CLAUDE.md +0 -0
  16. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/HtmlElement.py +0 -0
  17. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/Testing.py +0 -0
  18. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/ai/__init__.py +0 -0
  19. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/ai/client.py +0 -0
  20. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/api/__init__.py +0 -0
  21. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/auth/__init__.py +0 -0
  22. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/cache/__init__.py +0 -0
  23. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/container/__init__.py +0 -0
  24. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/context/__init__.py +0 -0
  25. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/context/chunker.py +0 -0
  26. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/cache.py +0 -0
  27. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/constants.py +0 -0
  28. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/events.py +0 -0
  29. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/middleware.py +0 -0
  30. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/port_takeover.py +0 -0
  31. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/rate_limiter.py +0 -0
  32. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/request.py +0 -0
  33. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/core/response.py +0 -0
  34. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/crud/__init__.py +0 -0
  35. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/__init__.py +0 -0
  36. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/adapter.py +0 -0
  37. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/connection.py +0 -0
  38. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/database_url.py +0 -0
  39. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/firebird.py +0 -0
  40. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/mongodb.py +0 -0
  41. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/mssql.py +0 -0
  42. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/mysql.py +0 -0
  43. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/odbc.py +0 -0
  44. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/postgres.py +0 -0
  45. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/sql_translator.py +0 -0
  46. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/database/sqlite.py +0 -0
  47. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/debug/__init__.py +0 -0
  48. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/debug/error_overlay.py +0 -0
  49. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/dev_admin/__init__.py +0 -0
  50. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/dev_admin/metrics.py +0 -0
  51. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/dev_admin/plan.py +0 -0
  52. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/dev_admin/project_index.py +0 -0
  53. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/docs.py +0 -0
  54. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/docstore/__init__.py +0 -0
  55. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/dotenv/__init__.py +0 -0
  56. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/env.py +0 -0
  57. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/frond/FROND.md +0 -0
  58. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/frond/__init__.py +0 -0
  59. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/frond/compiler.py +0 -0
  60. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/frond/engine.py +0 -0
  61. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/frond/parser.py +0 -0
  62. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/auth/meta.json +0 -0
  63. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  64. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/database/meta.json +0 -0
  65. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  66. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/error-overlay/meta.json +0 -0
  67. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  68. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/orm/meta.json +0 -0
  69. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  70. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  71. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/queue/meta.json +0 -0
  72. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  73. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/rest-api/meta.json +0 -0
  74. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  75. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/templates/meta.json +0 -0
  76. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  77. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  78. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/graphql/__init__.py +0 -0
  79. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/i18n/__init__.py +0 -0
  80. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/mcp/__init__.py +0 -0
  81. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/mcp/protocol.py +0 -0
  82. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/mcp/tools.py +0 -0
  83. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/messenger/__init__.py +0 -0
  84. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/migration/__init__.py +0 -0
  85. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/migration/runner.py +0 -0
  86. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/mqtt/__init__.py +0 -0
  87. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/mqtt/message.py +0 -0
  88. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/orm/__init__.py +0 -0
  89. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/orm/fields.py +0 -0
  90. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/orm/point.py +0 -0
  91. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/__feedback/widget.js +0 -0
  92. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/css/tina4.css +0 -0
  93. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/css/tina4.min.css +0 -0
  94. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/favicon.ico +0 -0
  95. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/images/logo.svg +0 -0
  96. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  97. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/js/frond.js +0 -0
  98. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/js/frond.min.js +0 -0
  99. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  100. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/js/tina4.min.js +0 -0
  101. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/js/tina4js.min.js +0 -0
  102. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/swagger/index.html +0 -0
  103. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  104. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/query_builder/__init__.py +0 -0
  105. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue/amqp_url.py +0 -0
  106. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue/job.py +0 -0
  107. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue/kafka_backend.py +0 -0
  108. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue/rabbitmq_backend.py +0 -0
  109. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue_backends/__init__.py +0 -0
  110. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue_backends/kafka_backend.py +0 -0
  111. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue_backends/mongo_backend.py +0 -0
  112. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  113. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/realtime/__init__.py +0 -0
  114. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/realtime/models/Attachment.py +0 -0
  115. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/realtime/models/Channel.py +0 -0
  116. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/realtime/models/ChannelMember.py +0 -0
  117. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/realtime/models/Message.py +0 -0
  118. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/realtime/models/Workspace.py +0 -0
  119. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/realtime/models/__init__.py +0 -0
  120. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/realtime/storage.py +0 -0
  121. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/seeder/__init__.py +0 -0
  122. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/service/__init__.py +0 -0
  123. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/session/__init__.py +0 -0
  124. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/session_handlers/__init__.py +0 -0
  125. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/session_handlers/memcached_handler.py +0 -0
  126. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  127. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/session_handlers/redis_handler.py +0 -0
  128. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/session_handlers/valkey_handler.py +0 -0
  129. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/sso.py +0 -0
  130. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/swagger/__init__.py +0 -0
  131. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/components/crud.twig +0 -0
  132. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  133. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  134. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/docker/python/Dockerfile +0 -0
  135. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  136. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/errors/302.twig +0 -0
  137. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/errors/401.twig +0 -0
  138. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/errors/403.twig +0 -0
  139. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/errors/404.twig +0 -0
  140. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/errors/500.twig +0 -0
  141. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/errors/502.twig +0 -0
  142. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/errors/503.twig +0 -0
  143. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/errors/base.twig +0 -0
  144. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/frontend/README.md +0 -0
  145. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/templates/readme.md +0 -0
  146. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/test/__init__.py +0 -0
  147. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/test_client/__init__.py +0 -0
  148. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/validator/__init__.py +0 -0
  161. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/websocket/backplane.py +0 -0
  162. {tina4_python-3.13.104 → tina4_python-3.13.106}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.104
3
+ Version: 3.13.106
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.104"
3
+ version = "3.13.106"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.104"
54
+ return "3.13.106"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -74,6 +74,11 @@ from tina4_python.core.router import ( # noqa: E402, F401
74
74
  noauth, secured, cached, middleware, template,
75
75
  Router, RouteGroup,
76
76
  )
77
+ # websocket is not in that tuple. The tina4_python.websocket *subpackage* is
78
+ # itself callable (see tina4_python/websocket/__init__.py) so
79
+ # `from tina4_python import websocket` reaches core.router.websocket without
80
+ # turning `import tina4_python.websocket as ws` into a function. A re-export
81
+ # here would do the latter.
77
82
  from tina4_python.core.constants import ( # noqa: E402, F401
78
83
  HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED, HTTP_NO_CONTENT,
79
84
  HTTP_MOVED, HTTP_REDIRECT, HTTP_NOT_MODIFIED,
@@ -825,13 +825,8 @@ def _seed(args):
825
825
 
826
826
  def _routes(args):
827
827
  """List all registered routes."""
828
- if Path("app.py").exists():
829
- sys.path.insert(0, str(Path.cwd()))
830
- import importlib
831
- importlib.import_module("app")
832
-
833
- from tina4_python.core.router import Router
834
- routes = Router.get_routes()
828
+ from tina4_python.core import discover_routes
829
+ routes = discover_routes("src/routes")
835
830
  if not routes:
836
831
  print("No routes registered.")
837
832
  return
@@ -19,7 +19,7 @@ from tina4_python.core.router import (
19
19
  from tina4_python.core.middleware import CorsMiddleware, RateLimiter
20
20
  from tina4_python.core.cache import Cache
21
21
  from tina4_python.core.events import on, off, emit, emit_async, once, listeners, events, clear as clear_events
22
- from tina4_python.core.server import run, resolve_config, handle, start, stop
22
+ from tina4_python.core.server import discover_routes, run, resolve_config, handle, start, stop
23
23
 
24
24
  __all__ = [
25
25
  "Request", "Response", "Router",
@@ -28,5 +28,5 @@ __all__ = [
28
28
  "CorsMiddleware", "RateLimiter",
29
29
  "Cache",
30
30
  "on", "off", "emit", "emit_async", "once", "listeners", "events", "clear_events",
31
- "run", "resolve_config", "handle", "start", "stop",
31
+ "discover_routes", "run", "resolve_config", "handle", "start", "stop",
32
32
  ]
@@ -817,9 +817,23 @@ def noauth():
817
817
  def decorator(fn):
818
818
  fn._noauth = True
819
819
  # If route was already registered (decorator applied after @get/@post),
820
- # update the route dict directly.
820
+ # update the route dict directly AND log the corrected state. The
821
+ # documented decorator order puts @noauth() ABOVE @get/@post, which
822
+ # means Python's bottom-up application runs @post first — so
823
+ # `_register_route` fires its "auth=required" line for a route that
824
+ # will be public a microsecond later. Without the corrective line
825
+ # below, the earlier log reads as definitive and misleads anyone
826
+ # reading startup output (issue #103: an AI reviewer drafted a false
827
+ # critical-security finding after reading the misleading line).
821
828
  if hasattr(fn, "_route_ref"):
822
- fn._route_ref._route["auth_required"] = False
829
+ route = fn._route_ref._route
830
+ was_required = route.get("auth_required", False)
831
+ route["auth_required"] = False
832
+ if was_required:
833
+ Log.debug(
834
+ f"Route auth updated: {route['method']} {route['path']} "
835
+ f"(auth=public via @noauth)"
836
+ )
823
837
  return fn
824
838
  return decorator
825
839
 
@@ -828,10 +842,19 @@ def secured():
828
842
  """Require auth on a GET route (which is public by default)."""
829
843
  def decorator(fn):
830
844
  fn._secured = True
831
- # If route was already registered (decorator applied after @get/@post),
832
- # update the route dict directly.
845
+ # See @noauth() above: emit a corrective log line if we flip the flag
846
+ # on an already-registered route so a reader of the startup log sees
847
+ # the true final auth state after every decorator has applied
848
+ # (issue #103).
833
849
  if hasattr(fn, "_route_ref"):
834
- fn._route_ref._route["auth_required"] = True
850
+ route = fn._route_ref._route
851
+ was_public = not route.get("auth_required", True)
852
+ route["auth_required"] = True
853
+ if was_public:
854
+ Log.debug(
855
+ f"Route auth updated: {route['method']} {route['path']} "
856
+ f"(auth=required via @secured)"
857
+ )
835
858
  # Same for a WebSocket route registered by @websocket() below this one.
836
859
  if hasattr(fn, "_ws_route_ref"):
837
860
  fn._ws_route_ref["auth_required"] = True
@@ -316,6 +316,79 @@ async def background_tick_loop(callback, interval: float, executor, shutdown):
316
316
  _discovered_mtimes: dict[str, float] = {}
317
317
 
318
318
 
319
+ def _cascade_reload_dependents(reloaded: str, root_pkg: str, visited: set[str]) -> None:
320
+ """After re-importing ``reloaded``, re-import any other in-scope module that
321
+ still holds a stale ``from <reloaded> import Foo`` reference.
322
+
323
+ Fixes https://github.com/tina4stack/tina4-python/issues/102 (hot reload
324
+ does not re-register ORM field metadata). Root cause: ``_auto_discover``
325
+ reloads the CHANGED module (say ``src.orm.Todo``) by ``del sys.modules[.]``
326
+ + ``importlib.import_module(.)``, which builds a fresh class object. But a
327
+ route module that did ``from src.orm.Todo import Todo`` at ITS first import
328
+ captured a reference to the OLD class, and unless that module ALSO
329
+ re-executes, its ``Todo(...)`` call still uses the old class -- with the
330
+ old ``_fields`` -- so a newly added column is silently absent from
331
+ ``to_dict()`` output. The DATA is written to the DB correctly (the ORM
332
+ knows the field via the new class), and the API just lies about it.
333
+
334
+ Detection: for every in-scope module already in ``sys.modules``, walk its
335
+ namespace and look for any attribute whose ``__module__`` matches the
336
+ reloaded module's name. That's the fingerprint of a ``from X import Y``
337
+ binding into an unchanged file. Re-import that file so its ``from`` binds
338
+ to the fresh class. Recurse (a dependent may itself be depended on).
339
+
340
+ Bounded to the discovery scope (``root_pkg`` and its subpackages) so we
341
+ never delete a framework or third-party module. ``visited`` breaks cycles
342
+ and prevents re-doing a module we already refreshed in this cascade.
343
+ """
344
+ # Snapshot: mutating sys.modules during the walk would break iteration.
345
+ for other_name in list(sys.modules):
346
+ if other_name in visited:
347
+ continue
348
+ if other_name == reloaded:
349
+ continue
350
+ if other_name != root_pkg and not other_name.startswith(root_pkg + "."):
351
+ continue
352
+ other = sys.modules.get(other_name)
353
+ if other is None or not hasattr(other, "__dict__"):
354
+ continue
355
+ # Any attribute whose __module__ names the reloaded module is a
356
+ # ``from X import Y`` binding into this file -- it is stale.
357
+ try:
358
+ attrs = vars(other)
359
+ except Exception:
360
+ continue
361
+ stale = False
362
+ for attr_name, attr in attrs.items():
363
+ if attr_name.startswith("__"):
364
+ continue
365
+ if getattr(attr, "__module__", None) == reloaded:
366
+ stale = True
367
+ break
368
+ if not stale:
369
+ continue
370
+ try:
371
+ from tina4_python.core.router import Router
372
+ Router.remove_routes_for_module(other_name)
373
+ del sys.modules[other_name]
374
+ importlib.import_module(other_name)
375
+ # Record so the next mtime tick sees this reload as authoritative.
376
+ try:
377
+ other_path = getattr(sys.modules[other_name], "__file__", None)
378
+ if other_path:
379
+ _discovered_mtimes[other_name] = Path(other_path).stat().st_mtime
380
+ except OSError:
381
+ pass
382
+ visited.add(other_name)
383
+ Log.info(f"Cascaded reload of dependent module: {other_name}")
384
+ # A module that imported from `reloaded` may itself be imported
385
+ # from by yet another module. Cascade the same detection on THIS
386
+ # module now that its class objects are fresh.
387
+ _cascade_reload_dependents(other_name, root_pkg, visited)
388
+ except Exception as exc:
389
+ Log.error(f"Cascade reload failed for {other_name}: {exc}")
390
+
391
+
319
392
  def _auto_discover(root_dir: str = "src"):
320
393
  """Auto-import all .py files in ``root_dir`` to trigger route decorators.
321
394
 
@@ -444,6 +517,14 @@ def _auto_discover(root_dir: str = "src"):
444
517
  f"Reloaded changed module: {module_name}"
445
518
  + (f" (dropped {dropped} stale route(s))" if dropped else "")
446
519
  )
520
+ # Cascade to any in-scope module that captured a
521
+ # ``from <module_name> import Foo`` binding at ITS first
522
+ # import -- that binding still points at the OLD class
523
+ # object we just replaced, so the dependent silently uses
524
+ # stale field metadata / stale globals until it too
525
+ # re-executes. See _cascade_reload_dependents above and
526
+ # issue #102.
527
+ _cascade_reload_dependents(module_name, root_pkg, {module_name})
447
528
  else:
448
529
  # Out-of-scope module changed — record mtime so we don't
449
530
  # keep re-evaluating it, but do not re-import it.
@@ -468,6 +549,13 @@ def _auto_discover(root_dir: str = "src"):
468
549
  pass
469
550
 
470
551
 
552
+ def discover_routes(root_dir: str = "src/routes"):
553
+ """Discover application routes without booting the application server."""
554
+ _auto_discover(root_dir)
555
+ from tina4_python.core.router import Router
556
+ return Router.get_routes()
557
+
558
+
471
559
  def _record_broken_import(py_file: Path, error: Exception) -> None:
472
560
  """Write a .broken sentinel so /health and the dev dashboard surface
473
561
  auto-discover failures instead of burying them in the console."""
@@ -1363,12 +1363,24 @@ class ORM(metaclass=ORMMeta):
1363
1363
  def clear_cache(cls) -> None:
1364
1364
  """Invalidate every cached query that touches this model's table.
1365
1365
 
1366
- Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that
1367
- reads this table is busted too (it carries this table's tag), while a
1368
- query that never touches this table is left intact. Called after every
1369
- ORM write (save/delete/force_delete/restore) so a read-after-write never
1370
- serves a stale/deleted row (CACHE-DEC-01)."""
1366
+ Tag-scoped in the ORM layer (a cached JOIN on another model that reads
1367
+ this table is busted too because it carries this table's tag; a query
1368
+ that never touches this table is left intact), then cascaded to the
1369
+ DB layer on this model's bound connection so an out-of-band write /
1370
+ deliberate refresh / race-with-another-process cannot leave stale rows
1371
+ in db.fetch()'s persistent cache. Called after every ORM write
1372
+ (save/delete/force_delete/restore) so a read-after-write never serves
1373
+ a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
1374
+ DB-layer cascade -- previously the two cache layers disagreed
1375
+ under TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true."""
1371
1376
  _query_cache.clear_tag(cls._get_table().lower())
1377
+ try:
1378
+ cls._get_db().cache_clear()
1379
+ except Exception:
1380
+ # A resolvable DB is not guaranteed at every clear_cache call
1381
+ # site (module-import time in odd bootstraps, tests that mutate
1382
+ # bindings); never let a cache-clear crash a save/delete.
1383
+ pass
1372
1384
 
1373
1385
  @classmethod
1374
1386
  def clear_rel_cache(cls) -> None:
@@ -182,7 +182,20 @@ class Queue:
182
182
  processed += 1
183
183
 
184
184
  def size(self, status: str = "pending") -> int:
185
- """Count jobs by status."""
185
+ """Count jobs by status.
186
+
187
+ ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
188
+ but-attempted ones, because they live in the pending queue under the
189
+ auto-retry lifecycle (see failed()).
190
+ ``"reserved"`` counts jobs a consumer has popped but not yet
191
+ completed/failed (in-flight against the visibility timeout).
192
+ ``"completed"`` counts jobs the consumer has finished successfully.
193
+ ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
194
+ the dead-letter store -- jobs whose attempts >= max_retries and that
195
+ have given up. Use dead_letters() to list them. Retryable-but-
196
+ attempted jobs are NOT counted by size("failed"); use failed() to
197
+ list them or size("pending") to include them in a total.
198
+ """
186
199
  return self._backend.size(status)
187
200
 
188
201
  def purge(self, status: str, max_retries: int = None) -> int:
@@ -194,14 +207,27 @@ class Queue:
194
207
  return self._backend.retry_failed(max_retries=max_retries if max_retries is not None else self.max_retries)
195
208
 
196
209
  def failed(self) -> list[dict]:
197
- """Get jobs that failed but are still eligible for retry."""
210
+ """Get jobs that failed at least once but are still being retried
211
+ (0 < attempts < max_retries). These live in the pending queue under
212
+ the auto-retry lifecycle (fail() re-queues them with an incremented
213
+ attempts count and a retry_backoff delay) so pop() picks them up
214
+ again. They are NOT counted by size("failed") -- that alias counts
215
+ the dead-letter store, matching dead_letters(). To include retryable-
216
+ failed jobs in a total, use size("pending"). Terminal failures are
217
+ returned by dead_letters()."""
198
218
  return self._backend.failed()
199
219
 
200
220
  def dead_letters(self, max_retries: int = None) -> list[Job]:
201
- """Get jobs that exceeded max retries.
221
+ """Get jobs that exceeded max_retries -- terminal failures.
222
+
223
+ Same set counted by size("failed") / size("dead") / size("dead_letter")
224
+ (three aliases for the dead-letter store). To LIST retryable-but-
225
+ attempted jobs (attempts > 0 AND attempts < max_retries) that are
226
+ still being auto-retried, use failed() -- those live in the pending
227
+ queue and are NOT dead letters.
202
228
 
203
229
  Returns ``Job`` objects with the failure reason on ``.error``,
204
- not raw dicts — so callers can iterate uniformly with the rest
230
+ not raw dicts -- so callers can iterate uniformly with the rest
205
231
  of the queue API (``for job in queue.consume()``).
206
232
 
207
233
  for job in queue.dead_letters():
@@ -228,13 +254,22 @@ class Queue:
228
254
  ]
229
255
 
230
256
  def retry(self, job_id: str = None, delay_seconds: int = 0) -> bool:
231
- """Retry a failed job by ID, or all dead-letter jobs if no ID given."""
257
+ """Retry a failed job by ID, or all dead-letter jobs if no ID given.
258
+
259
+ The no-arg branch materialises every retry_job() call before returning
260
+ so ALL dead letters are revived. Pre-3.13.105 (PY-12-04) this branch
261
+ used ``any(retry_job(j.id) for j in dead)`` -- Python's short-circuit
262
+ ``any`` stopped iterating as soon as the FIRST job revived, silently
263
+ leaving the rest in the dead-letter store.
264
+ """
232
265
  if job_id is None:
233
266
  dead = self.dead_letters()
234
267
  if not dead:
235
268
  return False
236
- # dead is now list[Job] — pull the id off each
237
- return any(self._backend.retry_job(j.id, delay_seconds) for j in dead)
269
+ # Materialise: iterate EVERY dead letter, then reduce -- never a
270
+ # generator inside any().
271
+ results = [self._backend.retry_job(j.id, delay_seconds) for j in dead]
272
+ return any(results)
238
273
  return self._backend.retry_job(job_id, delay_seconds)
239
274
 
240
275
  def clear(self) -> int:
@@ -547,9 +547,23 @@ class LiteBackend:
547
547
  def retry(self, job: Job, delay_seconds: int = 0):
548
548
  """Explicit re-queue requested by the caller (job.retry()).
549
549
 
550
- Always re-enqueues regardless of the retry limit — this is a manual
551
- override, distinct from the automatic fail() path.
550
+ Always re-enqueues regardless of the retry limit -- this is a manual
551
+ override, distinct from the automatic fail() path. Cleans up BOTH the
552
+ reservation record and any dead-letter file for this id, so a caller
553
+ who iterates dead_letters() and calls .retry() on each doesn't leave
554
+ the dead-letter directory carrying duplicates (PY-12-05, 3.13.105).
555
+ Aligns with retry_job(id) which had always unlinked the dead-letter
556
+ file -- two spellings of the same intent that previously diverged.
552
557
  """
553
558
  self._clear_reservation(job.id)
559
+ # Drop any dead-letter file for this id BEFORE the re-queue -- if this
560
+ # job came from dead_letters() it lives in _failed_dir() and would
561
+ # otherwise stay on disk while a fresh pending file appears in
562
+ # _queue_dir(), so the next dead_letters() call reports the job again
563
+ # and a consumer processes it twice.
564
+ try:
565
+ os.unlink(os.path.join(self._failed_dir(), f"{job.id}.queue-data"))
566
+ except FileNotFoundError:
567
+ pass
554
568
  job.attempts += 1
555
569
  self._requeue(job, delay_seconds=delay_seconds, error=None)
@@ -93,9 +93,26 @@ class MongoBackend:
93
93
  return 0
94
94
  return self._backend.size(self._topic)
95
95
 
96
- def purge(self, status: str = "completed"):
97
- if status == "pending":
98
- self._backend.clear(self._topic)
96
+ def purge(self, status: str = "completed") -> int:
97
+ """Delete every doc in this queue whose status matches.
98
+
99
+ Pre-3.13.105 (PY-12-08) this returned None (not a count), swallowed
100
+ every status other than ``pending``, and even for ``pending`` called
101
+ ``clear()`` which delete_many({"topic": topic}) -- nuking ALL statuses
102
+ under that topic, not just pending. Now every status is honoured, the
103
+ dead-letter states route to the ``.dead_letter`` topic namespace, and
104
+ every path returns ``deleted_count`` so a caller can log/assert what
105
+ was purged.
106
+ """
107
+ self._backend._ensure_connected()
108
+ if status in ("dead", "dead_letter", "failed"):
109
+ dl_topic = f"{self._topic}.dead_letter"
110
+ result = self._backend._collection.delete_many({"topic": dl_topic})
111
+ else:
112
+ result = self._backend._collection.delete_many(
113
+ {"topic": self._topic, "status": status}
114
+ )
115
+ return result.deleted_count
99
116
 
100
117
  def retry_failed(self, max_retries: int = None) -> int:
101
118
  # Accept max_retries to match the LiteBackend contract (Queue passes it
@@ -154,16 +171,57 @@ class MongoBackend:
154
171
  for d in docs]
155
172
 
156
173
  def retry_job(self, job_id: str, delay_seconds: int = 0) -> bool:
157
- """Reset a failed job back to pending by ID."""
174
+ """Revive a specific dead-letter job by ID back to the pending queue.
175
+
176
+ Pre-3.13.105 this searched
177
+ ``{_id: job_id, topic: self._topic, status: "failed"}`` -- three
178
+ separate reasons that filter could never match a dead letter:
179
+
180
+ * ``dead_letter()`` writes a NEW doc with a fresh ``_id`` (the
181
+ original job id is preserved on the doc's ``data.id`` field), so
182
+ ``_id: job_id`` never hit.
183
+ * The dead-letter topic is ``f"{topic}.dead_letter"``, not
184
+ ``self._topic``.
185
+ * Dead letters carry ``status="dead"`` (the original doc under
186
+ ``self._topic`` is ``status="completed"`` after the ack).
187
+
188
+ The corrected flow looks the doc up in the dead-letter namespace by
189
+ the original id, deletes the dead-letter record, and re-enqueues the
190
+ original as pending -- upserting on the small chance the original
191
+ completed doc has been purged, so a retry after a housekeeping run
192
+ still works. Mirrors LiteBackend.retry_job() and matches
193
+ Queue.retry(id) callers' expectation that retry always revives if a
194
+ dead letter exists for the id.
195
+ """
158
196
  self._backend._ensure_connected()
159
- available = _now() if delay_seconds == 0 else _future(delay_seconds)
160
- result = self._backend._collection.update_one(
161
- {"_id": job_id, "topic": self._topic, "status": "failed"},
197
+ dl_topic = f"{self._topic}.dead_letter"
198
+ dl_doc = self._backend._collection.find_one(
199
+ {"topic": dl_topic, "data.id": job_id}
200
+ )
201
+ if dl_doc is None:
202
+ return False
203
+ # Drop the dead-letter doc first so an interrupted retry never leaves
204
+ # both a dead-letter and a fresh pending doc for the same id.
205
+ self._backend._collection.delete_one({"_id": dl_doc["_id"]})
206
+ available = _now() if delay_seconds <= 0 else _future(delay_seconds)
207
+ # dead_letter() stored the ORIGINAL enqueue message under the doc's
208
+ # ``data`` field (see queue_backends/mongo_backend.py::dead_letter);
209
+ # its ``payload`` key is the enqueue payload the consumer needs.
210
+ original = dl_doc.get("data") if isinstance(dl_doc.get("data"), dict) else {}
211
+ payload = original.get("payload", original.get("data", {}))
212
+ priority = original.get("priority", dl_doc.get("priority", 0))
213
+ # Upsert: reset the original doc if it still exists, otherwise
214
+ # re-create it from the dead-letter payload so a purged/gone original
215
+ # is not silently a retry-no-op.
216
+ self._backend._collection.update_one(
217
+ {"_id": job_id, "topic": self._topic},
162
218
  {"$set": {"status": "pending", "error": None,
163
- "available_at": available},
219
+ "available_at": available, "reserved_at": None,
220
+ "priority": priority, "payload": payload},
164
221
  "$inc": {"attempts": 1}},
222
+ upsert=True,
165
223
  )
166
- return result.modified_count == 1
224
+ return True
167
225
 
168
226
  def clear(self) -> int:
169
227
  # The only one of this adapter's six _collection users that forgot to
@@ -22,6 +22,8 @@ import os
22
22
  import uuid
23
23
  import time
24
24
  from typing import Callable
25
+ import sys as _sys
26
+ from types import ModuleType as _ModuleType
25
27
 
26
28
  MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
27
29
 
@@ -961,3 +963,34 @@ __all__ = [
961
963
  "OP_TEXT", "OP_BINARY", "OP_CLOSE", "OP_PING", "OP_PONG",
962
964
  "CLOSE_NORMAL", "CLOSE_GOING_AWAY", "CLOSE_PROTOCOL_ERROR", "CLOSE_TOO_LARGE",
963
965
  ]
966
+
967
+
968
+ # ── `@websocket("/path")` on the package name ───────────────────────────────
969
+ # `tina4_python.websocket` is two things at once: this RFC 6455 subpackage,
970
+ # and — to every reader of the docs — the route decorator beside @get/@post.
971
+ # Python binds a submodule onto its parent as soon as the submodule loads, so
972
+ #
973
+ # from tina4_python import websocket
974
+ # @websocket("/ws") # TypeError: 'module' object is not callable
975
+ #
976
+ # died at decorate time and auto-discovery dropped every route from that line
977
+ # onward.
978
+ #
979
+ # A re-export in tina4_python/__init__.py AFTER core.server has already loaded
980
+ # this subpackage is stable (importlib setattr happens on first load only).
981
+ # That still fails the other public surface: `import tina4_python.websocket as
982
+ # ws` would then return the function, so `ws.WebSocketServer` breaks. Making
983
+ # this module callable keeps both: the package name decorates, and the
984
+ # subpackage stays a module. Forward *args/**kwargs so this spelling cannot
985
+ # drift from core.router.websocket. Guard sys.modules — a loader that execs
986
+ # this file without registering it must not KeyError.
987
+ class _CallableWebSocketModule(_ModuleType):
988
+ def __call__(self, *args, **kwargs):
989
+ from tina4_python.core.router import websocket as _websocket_route
990
+
991
+ return _websocket_route(*args, **kwargs)
992
+
993
+
994
+ _self = _sys.modules.get(__name__)
995
+ if _self is not None:
996
+ _self.__class__ = _CallableWebSocketModule