tina4-python 3.13.22__tar.gz → 3.13.24__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.22 → tina4_python-3.13.24}/PKG-INFO +1 -1
  2. {tina4_python-3.13.22 → tina4_python-3.13.24}/pyproject.toml +1 -1
  3. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/CLAUDE.md +10 -3
  4. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/cache/__init__.py +375 -16
  6. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/server.py +9 -0
  7. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/connection.py +102 -3
  8. {tina4_python-3.13.22 → tina4_python-3.13.24}/.gitignore +0 -0
  9. {tina4_python-3.13.22 → tina4_python-3.13.24}/README.md +0 -0
  10. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/HtmlElement.py +0 -0
  11. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/Testing.py +0 -0
  12. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/ai/__init__.py +0 -0
  13. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/api/__init__.py +0 -0
  14. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/auth/__init__.py +0 -0
  15. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/cli/__init__.py +0 -0
  16. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/container/__init__.py +0 -0
  17. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/__init__.py +0 -0
  18. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/cache.py +0 -0
  19. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/constants.py +0 -0
  20. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/events.py +0 -0
  21. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/middleware.py +0 -0
  22. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/rate_limiter.py +0 -0
  23. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/request.py +0 -0
  24. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/response.py +0 -0
  25. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/core/router.py +0 -0
  26. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/crud/__init__.py +0 -0
  27. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/__init__.py +0 -0
  28. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/adapter.py +0 -0
  29. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/firebird.py +0 -0
  30. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/mongodb.py +0 -0
  31. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/mssql.py +0 -0
  32. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/mysql.py +0 -0
  33. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/odbc.py +0 -0
  34. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/postgres.py +0 -0
  35. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/database/sqlite.py +0 -0
  36. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/debug/__init__.py +0 -0
  37. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/dev_admin/plan.py +0 -0
  41. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/dev_admin/project_index.py +0 -0
  42. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/docs.py +0 -0
  43. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/dotenv/__init__.py +0 -0
  44. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/env.py +0 -0
  45. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/frond/FROND.md +0 -0
  46. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/frond/__init__.py +0 -0
  47. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/frond/engine.py +0 -0
  48. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/auth/meta.json +0 -0
  49. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  50. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/database/meta.json +0 -0
  51. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  52. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/error-overlay/meta.json +0 -0
  53. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  54. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/orm/meta.json +0 -0
  55. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  56. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  57. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/queue/meta.json +0 -0
  58. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  59. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/rest-api/meta.json +0 -0
  60. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  61. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/templates/meta.json +0 -0
  62. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  63. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  64. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/graphql/__init__.py +0 -0
  65. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/i18n/__init__.py +0 -0
  66. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/mcp/__init__.py +0 -0
  67. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/mcp/protocol.py +0 -0
  68. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/mcp/tools.py +0 -0
  69. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/messenger/__init__.py +0 -0
  70. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/migration/__init__.py +0 -0
  71. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/migration/runner.py +0 -0
  72. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/orm/__init__.py +0 -0
  73. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/orm/fields.py +0 -0
  74. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/orm/model.py +0 -0
  75. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.22 → tina4_python-3.13.24}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.22 → tina4_python-3.13.24}/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.22
3
+ Version: 3.13.24
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.22"
3
+ version = "3.13.24"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -1457,10 +1457,17 @@ clear_cache() # flush all entries and reset stats
1457
1457
  ### Environment variables
1458
1458
 
1459
1459
  ```bash
1460
+ TINA4_CACHE_BACKEND=memory # memory (default) | file | redis | valkey | memcached | mongodb | database
1461
+ TINA4_CACHE_URL= # connection for redis/valkey/memcached/mongodb, OR a SQL URL for database (falls back to TINA4_DATABASE_URL)
1462
+ TINA4_CACHE_USERNAME= # credentials (mirrors TINA4_DATABASE_USERNAME); may also be embedded in TINA4_CACHE_URL
1463
+ TINA4_CACHE_PASSWORD= # e.g. redis://user:pass@host, redis://:pass@host, mongodb://user:pass@host. memcached is unauthenticated
1460
1464
  TINA4_CACHE_TTL=60 # Default TTL in seconds (default: 60)
1461
1465
  TINA4_CACHE_MAX_ENTRIES=1000 # Max cached entries (default: 1000)
1466
+ TINA4_CACHE_DIR=data/cache # Directory for the file backend (default: data/cache)
1462
1467
  ```
1463
1468
 
1469
+ The response/KV cache supports seven backends, selected by `TINA4_CACHE_BACKEND`. **Graceful fallback**: if a configured backend's driver is missing or the service/credentials are unreachable or wrong, the cache logs a warning and falls back to the **file** backend — a real persistent cache, never a silent no-op.
1470
+
1464
1471
  ## DI Container — Dependency Injection
1465
1472
 
1466
1473
  Lightweight, thread-safe dependency injection container with transient and singleton registrations.
@@ -1836,13 +1843,13 @@ async def dashboard(request, response):
1836
1843
  ## v3 Features Summary
1837
1844
 
1838
1845
  - **55 built-in features**, zero third-party dependencies
1839
- - **2,857 tests** passing across all modules
1846
+ - **2,899 tests** passing across all modules
1840
1847
  - **Production server auto-detect**: `tina4python serve --production` auto-installs uvicorn
1841
1848
  - **`tina4python generate`**: model, route, migration, middleware scaffolding
1842
- - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`, `cache_stats()`, `cache_clear()`)
1849
+ - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **on by default** (`TINA4_AUTO_CACHING=true`, TTL `TINA4_AUTO_CACHING_TTL=5`s) dedupes identical reads within a request and flushes on writes; persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL` so instances share one cache with global write-invalidation; `cache_stats()` reports `mode` (request/persistent/off) and `backend`, `cache_clear()`
1843
1850
  - **Sessions**: 4 backends (file, Redis/Valkey, MongoDB, database)
1844
1851
  - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
1845
- - **Cache**: memory/Redis/file backends
1852
+ - **Cache**: unified backend set — memory (default), file, redis, valkey, memcached, mongodb, database — via `TINA4_CACHE_BACKEND` (+ `TINA4_CACHE_URL`/credentials); file-backend fallback if a backend is unreachable
1846
1853
  - **Messenger**: .env driven SMTP/IMAP
1847
1854
  - **ORM relationships**: `has_many`, `has_one`, `belongs_to` with eager loading (`include=`)
1848
1855
  - **Frond pre-compilation**: 2.8x template render improvement, `Frond.clear_cache()`
@@ -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.22"
11
+ __version__ = "3.13.24"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -66,6 +66,12 @@ class _CacheBackend:
66
66
  def name(self) -> str:
67
67
  raise NotImplementedError
68
68
 
69
+ def is_available(self) -> bool:
70
+ """Whether this backend is actually usable (driver present + service
71
+ reachable). Local backends are always available; network/driver backends
72
+ override this so the factory can fall back to the file backend."""
73
+ return True
74
+
69
75
 
70
76
  # ── Memory backend ─────────────────────────────────────────────────
71
77
 
@@ -141,34 +147,58 @@ class _RedisBackend(_CacheBackend):
141
147
  """Redis / Valkey backend. Uses the ``redis`` package if available,
142
148
  otherwise falls back to raw RESP protocol over TCP."""
143
149
 
144
- def __init__(self, url: str = "redis://localhost:6379", max_entries: int = 1000):
150
+ def __init__(self, url: str = "redis://localhost:6379", max_entries: int = 1000,
151
+ name: str = "redis"):
145
152
  self._url = url
146
153
  self._max_entries = max_entries
154
+ self._name = name
147
155
  self._hits = 0
148
156
  self._misses = 0
149
157
  self._prefix = "tina4:cache:"
150
158
  self._client = None
151
159
  self._use_raw = False
152
160
 
153
- # Parse URL
154
- cleaned = url.replace("redis://", "")
155
- parts = cleaned.split(":")
156
- self._host = parts[0] or "localhost"
157
- self._port = int(parts[1].split("/")[0]) if len(parts) > 1 else 6379
158
- db_part = cleaned.split("/")
159
- self._db = int(db_part[1]) if len(db_part) > 1 and db_part[1] else 0
161
+ # Parse URL: scheme://[user[:password]@]host[:port][/db]. Credentials may
162
+ # also be supplied via TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD
163
+ # (mirrors TINA4_DATABASE_USERNAME / TINA4_DATABASE_PASSWORD).
164
+ from urllib.parse import urlparse, unquote
165
+ parsed = urlparse(url if "://" in url else "redis://" + url)
166
+ self._host = parsed.hostname or "localhost"
167
+ self._port = parsed.port or 6379
168
+ db_path = (parsed.path or "").lstrip("/")
169
+ self._db = int(db_path) if db_path.isdigit() else 0
170
+ self._username = (unquote(parsed.username) if parsed.username
171
+ else os.environ.get("TINA4_CACHE_USERNAME", "")) or None
172
+ self._password = (unquote(parsed.password) if parsed.password
173
+ else os.environ.get("TINA4_CACHE_PASSWORD", "")) or None
160
174
 
161
175
  # Try the redis package first
162
176
  try:
163
177
  import redis as redis_pkg
164
- self._client = redis_pkg.Redis(
165
- host=self._host, port=self._port, db=self._db,
166
- decode_responses=True, socket_timeout=5,
167
- )
178
+ kwargs = dict(host=self._host, port=self._port, db=self._db,
179
+ decode_responses=True, socket_timeout=5)
180
+ if self._password:
181
+ kwargs["password"] = self._password
182
+ if self._username:
183
+ kwargs["username"] = self._username
184
+ self._client = redis_pkg.Redis(**kwargs)
168
185
  self._client.ping()
186
+ self._available = True
169
187
  except Exception:
170
188
  self._client = None
171
189
  self._use_raw = True
190
+ # No client — usable only if the server answers (and authenticates).
191
+ self._available = self._probe()
192
+
193
+ def _probe(self) -> bool:
194
+ # Real AUTH+PING handshake so wrong credentials also fall back to file.
195
+ try:
196
+ return self._resp_command("PING") == "PONG"
197
+ except Exception:
198
+ return False
199
+
200
+ def is_available(self) -> bool:
201
+ return self._available
172
202
 
173
203
  def _resp_command(self, *args) -> str | None:
174
204
  """Send a command using raw RESP protocol over TCP."""
@@ -181,6 +211,16 @@ class _RedisBackend(_CacheBackend):
181
211
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
182
212
  sock.settimeout(5)
183
213
  sock.connect((self._host, self._port))
214
+ if self._password:
215
+ if self._username:
216
+ auth = (f"*3\r\n$4\r\nAUTH\r\n${len(self._username)}\r\n{self._username}\r\n"
217
+ f"${len(self._password)}\r\n{self._password}\r\n")
218
+ else:
219
+ auth = f"*2\r\n$4\r\nAUTH\r\n${len(self._password)}\r\n{self._password}\r\n"
220
+ sock.sendall(auth.encode())
221
+ if not sock.recv(1024).startswith(b"+"):
222
+ sock.close()
223
+ return None
184
224
  if self._db != 0:
185
225
  select_cmd = f"*2\r\n$6\r\nSELECT\r\n${len(str(self._db))}\r\n{self._db}\r\n"
186
226
  sock.sendall(select_cmd.encode())
@@ -280,11 +320,11 @@ class _RedisBackend(_CacheBackend):
280
320
  "hits": self._hits,
281
321
  "misses": self._misses,
282
322
  "size": size,
283
- "backend": "redis",
323
+ "backend": self._name,
284
324
  }
285
325
 
286
326
  def name(self) -> str:
287
- return "redis"
327
+ return self._name
288
328
 
289
329
 
290
330
  # ── File backend ───────────────────────────────────────────────────
@@ -386,6 +426,295 @@ class _FileBackend(_CacheBackend):
386
426
  return "file"
387
427
 
388
428
 
429
+ # ── Valkey backend (Redis-protocol compatible) ─────────────────────
430
+
431
+
432
+ class _ValkeyBackend(_RedisBackend):
433
+ """Valkey backend. Valkey speaks the Redis wire protocol, so it reuses the
434
+ Redis client / raw-RESP transport and only reports a different name."""
435
+
436
+ def __init__(self, url: str = "valkey://localhost:6379", max_entries: int = 1000):
437
+ super().__init__(url=url.replace("valkey://", "redis://"),
438
+ max_entries=max_entries, name="valkey")
439
+
440
+
441
+ # ── Memcached backend (zero-dependency text protocol) ──────────────
442
+
443
+
444
+ class _MemcachedBackend(_CacheBackend):
445
+ """Memcached backend using the zero-dependency text protocol over TCP."""
446
+
447
+ def __init__(self, url: str = "memcached://localhost:11211", max_entries: int = 1000):
448
+ cleaned = url.replace("memcached://", "").replace("memcache://", "")
449
+ parts = cleaned.split("/")[0].split(":")
450
+ self._host = parts[0] or "localhost"
451
+ self._port = int(parts[1]) if len(parts) > 1 and parts[1] else 11211
452
+ self._max_entries = max_entries
453
+ self._prefix = "tina4:cache:"
454
+ self._hits = 0
455
+ self._misses = 0
456
+ self._available = self._command(b"version\r\n", b"\r\n").startswith(b"VERSION")
457
+
458
+ def is_available(self) -> bool:
459
+ return self._available
460
+
461
+ def _mc_key(self, key: str) -> str:
462
+ # Hash to a safe, bounded key (memcached keys: no spaces/control, <=250 chars)
463
+ return self._prefix + hashlib.sha256(key.encode()).hexdigest()
464
+
465
+ def _command(self, payload: bytes, terminator: bytes) -> bytes:
466
+ try:
467
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
468
+ sock.settimeout(5)
469
+ sock.connect((self._host, self._port))
470
+ sock.sendall(payload)
471
+ buf = b""
472
+ while terminator not in buf:
473
+ chunk = sock.recv(4096)
474
+ if not chunk:
475
+ break
476
+ buf += chunk
477
+ sock.close()
478
+ return buf
479
+ except Exception:
480
+ return b""
481
+
482
+ def get(self, key: str):
483
+ resp = self._command(f"get {self._mc_key(key)}\r\n".encode(), b"END\r\n")
484
+ if resp.startswith(b"VALUE"):
485
+ try:
486
+ header, rest = resp.split(b"\r\n", 1)
487
+ nbytes = int(header.split()[3])
488
+ self._hits += 1
489
+ return json.loads(rest[:nbytes].decode())
490
+ except Exception:
491
+ pass
492
+ self._misses += 1
493
+ return None
494
+
495
+ def set(self, key: str, value, ttl: int):
496
+ data = json.dumps(value, default=str).encode()
497
+ exptime = ttl if ttl > 0 else 0
498
+ payload = f"set {self._mc_key(key)} 0 {exptime} {len(data)}\r\n".encode() + data + b"\r\n"
499
+ self._command(payload, b"\r\n")
500
+
501
+ def delete(self, key: str) -> bool:
502
+ resp = self._command(f"delete {self._mc_key(key)}\r\n".encode(), b"\r\n")
503
+ return resp.startswith(b"DELETED")
504
+
505
+ def clear(self):
506
+ self._hits = 0
507
+ self._misses = 0
508
+ self._command(b"flush_all\r\n", b"\r\n")
509
+
510
+ def stats(self) -> dict:
511
+ size = 0
512
+ resp = self._command(b"stats\r\n", b"END\r\n")
513
+ for line in resp.split(b"\r\n"):
514
+ if line.startswith(b"STAT curr_items "):
515
+ try:
516
+ size = int(line.split()[2])
517
+ except (ValueError, IndexError):
518
+ pass
519
+ return {"hits": self._hits, "misses": self._misses, "size": size, "backend": "memcached"}
520
+
521
+ def name(self) -> str:
522
+ return "memcached"
523
+
524
+
525
+ # ── MongoDB backend (TTL collection) ───────────────────────────────
526
+
527
+
528
+ class _MongoBackend(_CacheBackend):
529
+ """MongoDB backend backed by a TTL collection. Requires ``pymongo``.
530
+
531
+ Reuses the same connection style as the Mongo session handler; the cache
532
+ lives in collection ``tina4_cache`` with a TTL index on ``expires_at``."""
533
+
534
+ def __init__(self, url: str = "mongodb://localhost:27017", max_entries: int = 1000):
535
+ self._max_entries = max_entries
536
+ self._hits = 0
537
+ self._misses = 0
538
+ self._coll = None
539
+ try:
540
+ import pymongo
541
+ mongo_kwargs = {"serverSelectionTimeoutMS": 5000}
542
+ # Credentials from env when not embedded in the URL (parity with DB layer).
543
+ if "@" not in url:
544
+ mu = os.environ.get("TINA4_CACHE_USERNAME")
545
+ mp = os.environ.get("TINA4_CACHE_PASSWORD")
546
+ if mu:
547
+ mongo_kwargs["username"] = mu
548
+ if mp:
549
+ mongo_kwargs["password"] = mp
550
+ client = pymongo.MongoClient(url, **mongo_kwargs)
551
+ try:
552
+ db = client.get_default_database()
553
+ except Exception:
554
+ db = None
555
+ if db is None:
556
+ db = client["tina4_cache"]
557
+ self._coll = db["tina4_cache"]
558
+ self._coll.create_index("expires_at", expireAfterSeconds=0)
559
+ client.admin.command("ping")
560
+ except Exception:
561
+ self._coll = None
562
+
563
+ def is_available(self) -> bool:
564
+ return self._coll is not None
565
+
566
+ def get(self, key: str):
567
+ if self._coll is None:
568
+ self._misses += 1
569
+ return None
570
+ try:
571
+ from datetime import datetime, timezone
572
+ doc = self._coll.find_one({"_id": key})
573
+ if not doc:
574
+ self._misses += 1
575
+ return None
576
+ exp = doc.get("expires_at")
577
+ if exp is not None and exp.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
578
+ self._coll.delete_one({"_id": key})
579
+ self._misses += 1
580
+ return None
581
+ self._hits += 1
582
+ return json.loads(doc["value"])
583
+ except Exception:
584
+ self._misses += 1
585
+ return None
586
+
587
+ def set(self, key: str, value, ttl: int):
588
+ if self._coll is None:
589
+ return
590
+ try:
591
+ from datetime import datetime, timedelta, timezone
592
+ doc = {"_id": key, "value": json.dumps(value, default=str)}
593
+ if ttl > 0:
594
+ doc["expires_at"] = datetime.now(timezone.utc) + timedelta(seconds=ttl)
595
+ self._coll.replace_one({"_id": key}, doc, upsert=True)
596
+ except Exception:
597
+ pass
598
+
599
+ def delete(self, key: str) -> bool:
600
+ if self._coll is None:
601
+ return False
602
+ try:
603
+ return self._coll.delete_one({"_id": key}).deleted_count > 0
604
+ except Exception:
605
+ return False
606
+
607
+ def clear(self):
608
+ self._hits = 0
609
+ self._misses = 0
610
+ if self._coll is not None:
611
+ try:
612
+ self._coll.delete_many({})
613
+ except Exception:
614
+ pass
615
+
616
+ def stats(self) -> dict:
617
+ size = 0
618
+ if self._coll is not None:
619
+ try:
620
+ size = self._coll.count_documents({})
621
+ except Exception:
622
+ pass
623
+ return {"hits": self._hits, "misses": self._misses, "size": size, "backend": "mongodb"}
624
+
625
+ def name(self) -> str:
626
+ return "mongodb"
627
+
628
+
629
+ # ── Database backend (cache table in any Tina4-supported DB) ───────
630
+
631
+
632
+ class _DatabaseBackend(_CacheBackend):
633
+ """Database backend — stores entries in a ``tina4_cache`` table in any
634
+ Tina4-supported database. Zero extra infrastructure; reuses the Database
635
+ layer. Its own connection has query caching disabled to avoid recursion."""
636
+
637
+ def __init__(self, url: str | None = None, max_entries: int = 1000):
638
+ from tina4_python.database.connection import Database
639
+ self._max_entries = max_entries
640
+ self._hits = 0
641
+ self._misses = 0
642
+ self._db = None
643
+ self._available = False
644
+ # The database backend reads TINA4_CACHE_URL (interpreted as a SQL URL),
645
+ # falling back to the app's own TINA4_DATABASE_URL — cache in the DB you
646
+ # already run, no extra connection var needed.
647
+ url = (url or os.environ.get("TINA4_CACHE_URL")
648
+ or os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/tina4.db"))
649
+ # The cache's own DB connection must not itself cache (no recursion).
650
+ prev_auto = os.environ.get("TINA4_AUTO_CACHING")
651
+ prev_db = os.environ.get("TINA4_DB_CACHE")
652
+ os.environ["TINA4_AUTO_CACHING"] = "false"
653
+ os.environ["TINA4_DB_CACHE"] = "false"
654
+ try:
655
+ self._db = Database(url)
656
+ self._db.execute(
657
+ "CREATE TABLE IF NOT EXISTS tina4_cache "
658
+ "(cache_key VARCHAR(255) PRIMARY KEY, value TEXT, expires_at DOUBLE PRECISION)"
659
+ )
660
+ self._available = True
661
+ except Exception:
662
+ self._available = False
663
+ finally:
664
+ for k, v in (("TINA4_AUTO_CACHING", prev_auto), ("TINA4_DB_CACHE", prev_db)):
665
+ if v is None:
666
+ os.environ.pop(k, None)
667
+ else:
668
+ os.environ[k] = v
669
+
670
+ def is_available(self) -> bool:
671
+ return self._available
672
+
673
+ def get(self, key: str):
674
+ row = self._db.fetch_one(
675
+ "SELECT value, expires_at FROM tina4_cache WHERE cache_key = ?", [key])
676
+ if not row:
677
+ self._misses += 1
678
+ return None
679
+ exp = row.get("expires_at")
680
+ if exp and float(exp) > 0 and time.time() > float(exp):
681
+ self._db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
682
+ self._misses += 1
683
+ return None
684
+ self._hits += 1
685
+ try:
686
+ return json.loads(row["value"])
687
+ except (json.JSONDecodeError, TypeError):
688
+ return row["value"]
689
+
690
+ def set(self, key: str, value, ttl: int):
691
+ exp = (time.time() + ttl) if ttl > 0 else 0
692
+ serialized = json.dumps(value, default=str)
693
+ self._db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
694
+ self._db.execute(
695
+ "INSERT INTO tina4_cache (cache_key, value, expires_at) VALUES (?, ?, ?)",
696
+ [key, serialized, exp])
697
+
698
+ def delete(self, key: str) -> bool:
699
+ existed = self._db.fetch_one(
700
+ "SELECT 1 AS x FROM tina4_cache WHERE cache_key = ?", [key]) is not None
701
+ self._db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
702
+ return existed
703
+
704
+ def clear(self):
705
+ self._hits = 0
706
+ self._misses = 0
707
+ self._db.execute("DELETE FROM tina4_cache")
708
+
709
+ def stats(self) -> dict:
710
+ row = self._db.fetch_one("SELECT COUNT(*) AS c FROM tina4_cache")
711
+ size = int(row["c"]) if row and row.get("c") is not None else 0
712
+ return {"hits": self._hits, "misses": self._misses, "size": size, "backend": "database"}
713
+
714
+ def name(self) -> str:
715
+ return "database"
716
+
717
+
389
718
  # ── Backend factory ────────────────────────────────────────────────
390
719
 
391
720
 
@@ -395,20 +724,50 @@ def _create_backend(
395
724
  max_entries: int | None = None,
396
725
  cache_dir: str | None = None,
397
726
  ) -> _CacheBackend:
398
- """Create a cache backend from explicit params or env vars."""
727
+ """Create a cache backend from explicit params or env vars.
728
+
729
+ Backends: memory (default), file, redis, valkey, memcached, mongodb, database.
730
+ """
399
731
  backend = backend or os.environ.get("TINA4_CACHE_BACKEND", "memory")
400
732
  max_entries = max_entries or int(os.environ.get("TINA4_CACHE_MAX_ENTRIES", "1000"))
401
733
 
402
734
  backend = backend.lower().strip()
403
735
  if backend == "redis":
404
736
  url = url or os.environ.get("TINA4_CACHE_URL", "redis://localhost:6379")
405
- return _RedisBackend(url=url, max_entries=max_entries)
737
+ be = _RedisBackend(url=url, max_entries=max_entries)
738
+ elif backend == "valkey":
739
+ url = url or os.environ.get("TINA4_CACHE_URL", "valkey://localhost:6379")
740
+ be = _ValkeyBackend(url=url, max_entries=max_entries)
741
+ elif backend in ("memcached", "memcache"):
742
+ url = url or os.environ.get("TINA4_CACHE_URL", "memcached://localhost:11211")
743
+ be = _MemcachedBackend(url=url, max_entries=max_entries)
744
+ elif backend in ("mongodb", "mongo"):
745
+ url = url or os.environ.get("TINA4_CACHE_URL", "mongodb://localhost:27017")
746
+ be = _MongoBackend(url=url, max_entries=max_entries)
747
+ elif backend in ("database", "db"):
748
+ be = _DatabaseBackend(url=url, max_entries=max_entries)
406
749
  elif backend == "file":
407
750
  cache_dir = cache_dir or os.environ.get("TINA4_CACHE_DIR", "data/cache")
408
751
  return _FileBackend(cache_dir=cache_dir, max_entries=max_entries)
409
752
  else:
410
753
  return _MemoryBackend(max_entries=max_entries)
411
754
 
755
+ # Graceful degradation: if the configured backend's driver is missing or the
756
+ # service is unreachable, fall back to the file backend (persistent, zero-dep,
757
+ # always available) rather than silently degrading to a no-op cache.
758
+ if not be.is_available():
759
+ try:
760
+ from tina4_python.debug import Log
761
+ Log.warning(
762
+ f"Cache backend '{backend}' is unavailable "
763
+ f"(driver missing or service unreachable) — falling back to 'file'."
764
+ )
765
+ except Exception:
766
+ pass
767
+ cache_dir = cache_dir or os.environ.get("TINA4_CACHE_DIR", "data/cache")
768
+ return _FileBackend(cache_dir=cache_dir, max_entries=max_entries)
769
+ return be
770
+
412
771
 
413
772
  # ── Cache entry (for response cache) ──────────────────────────────
414
773
 
@@ -1563,6 +1563,15 @@ async def handle(request: Request) -> Response:
1563
1563
  if swagger_resp is not None:
1564
1564
  return swagger_resp
1565
1565
 
1566
+ # Reset the request-scoped query cache so identical SELECTs are deduped
1567
+ # within this request but never served across requests (zero cross-request
1568
+ # staleness). No-op when TINA4_DB_CACHE=true (persistent mode) or disabled.
1569
+ try:
1570
+ from tina4_python.database.connection import Database as _Db
1571
+ _Db.reset_request_caches()
1572
+ except Exception:
1573
+ pass
1574
+
1566
1575
  # Route matching and dispatch
1567
1576
  route, params = Router.match(request.method, request.path)
1568
1577