tina4-python 3.13.32__tar.gz → 3.13.34__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.32 → tina4_python-3.13.34}/PKG-INFO +7 -5
  2. {tina4_python-3.13.32 → tina4_python-3.13.34}/README.md +6 -4
  3. {tina4_python-3.13.32 → tina4_python-3.13.34}/pyproject.toml +1 -1
  4. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue/__init__.py +10 -6
  6. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue/job.py +11 -2
  7. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue/lite_backend.py +147 -70
  8. {tina4_python-3.13.32 → tina4_python-3.13.34}/.gitignore +0 -0
  9. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/CLAUDE.md +0 -0
  10. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/HtmlElement.py +0 -0
  11. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/Testing.py +0 -0
  12. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/ai/__init__.py +0 -0
  13. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/api/__init__.py +0 -0
  14. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/auth/__init__.py +0 -0
  15. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/cache/__init__.py +0 -0
  16. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/cli/__init__.py +0 -0
  17. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/container/__init__.py +0 -0
  18. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/__init__.py +0 -0
  19. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/cache.py +0 -0
  20. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/constants.py +0 -0
  21. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/events.py +0 -0
  22. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/middleware.py +0 -0
  23. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/rate_limiter.py +0 -0
  24. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/request.py +0 -0
  25. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/response.py +0 -0
  26. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/router.py +0 -0
  27. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/core/server.py +0 -0
  28. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/crud/__init__.py +0 -0
  29. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/__init__.py +0 -0
  30. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/adapter.py +0 -0
  31. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/connection.py +0 -0
  32. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/firebird.py +0 -0
  33. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/mongodb.py +0 -0
  34. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/mssql.py +0 -0
  35. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/mysql.py +0 -0
  36. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/postgres.py +0 -0
  38. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/database/sqlite.py +0 -0
  39. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/debug/__init__.py +0 -0
  40. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/debug/error_overlay.py +0 -0
  41. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/dev_admin/__init__.py +0 -0
  42. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/dev_admin/metrics.py +0 -0
  43. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/dev_admin/plan.py +0 -0
  44. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/dev_admin/project_index.py +0 -0
  45. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/docs.py +0 -0
  46. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/dotenv/__init__.py +0 -0
  47. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/env.py +0 -0
  48. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/frond/FROND.md +0 -0
  49. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/frond/__init__.py +0 -0
  50. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/frond/engine.py +0 -0
  51. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/auth/meta.json +0 -0
  52. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  53. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/database/meta.json +0 -0
  54. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  55. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/error-overlay/meta.json +0 -0
  56. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  57. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/orm/meta.json +0 -0
  58. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  59. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  60. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/queue/meta.json +0 -0
  61. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  62. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/rest-api/meta.json +0 -0
  63. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  64. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/templates/meta.json +0 -0
  65. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  66. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  67. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/graphql/__init__.py +0 -0
  68. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/i18n/__init__.py +0 -0
  69. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/mcp/__init__.py +0 -0
  70. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/mcp/protocol.py +0 -0
  71. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/mcp/tools.py +0 -0
  72. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/messenger/__init__.py +0 -0
  73. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/migration/__init__.py +0 -0
  74. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/migration/runner.py +0 -0
  75. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/orm/__init__.py +0 -0
  76. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/orm/fields.py +0 -0
  77. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/orm/model.py +0 -0
  78. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/__feedback/widget.js +0 -0
  79. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/css/tina4.css +0 -0
  80. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/css/tina4.min.css +0 -0
  81. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/favicon.ico +0 -0
  82. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/images/logo.svg +0 -0
  83. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  84. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/js/frond.js +0 -0
  85. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/js/frond.min.js +0 -0
  86. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  87. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  88. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/js/tina4.min.js +0 -0
  89. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/js/tina4js.min.js +0 -0
  90. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/swagger/index.html +0 -0
  91. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  92. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/query_builder/__init__.py +0 -0
  93. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue/kafka_backend.py +0 -0
  94. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.32 → tina4_python-3.13.34}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.32 → tina4_python-3.13.34}/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.32
3
+ Version: 3.13.34
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
@@ -74,6 +74,8 @@ cd my-app && tina4 serve
74
74
 
75
75
  Open http://localhost:7146 — your app is running.
76
76
 
77
+ > **Two CLIs:** `tina4` is the cross-language Rust CLI — it scaffolds projects, runs the dev server, and watches files. `tina4python` is the Python package's own CLI for project tasks (`migrate`, `seed`, `generate`, `test`). This guide uses `tina4` to scaffold and run, and `tina4python` for those tasks.
78
+
77
79
  <details>
78
80
  <summary><strong>Without the Tina4 CLI (Docker / CI only)</strong></summary>
79
81
 
@@ -162,7 +164,7 @@ pip install tina4-python[dev-reload] # Hot-patching via jurigged
162
164
  ### 1. Create a project
163
165
 
164
166
  ```bash
165
- tina4python init my-app
167
+ tina4 init python my-app
166
168
  cd my-app
167
169
  ```
168
170
 
@@ -694,13 +696,13 @@ Frond.clear_cache()
694
696
  ## Environment
695
697
 
696
698
  ```bash
697
- SECRET=your-jwt-secret
699
+ TINA4_SECRET=your-jwt-secret
698
700
  TINA4_DATABASE_URL=sqlite:///data/app.db
699
701
  TINA4_DEBUG=true # Enable dev toolbar, error overlay
700
702
  TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
701
703
  TINA4_LOCALE=en # en, fr, af, zh, ja, es
702
- TINA4_SESSION_BACKEND=SessionFileHandler
703
- SWAGGER_TITLE=My API
704
+ TINA4_SESSION_BACKEND=file # file (default), redis, valkey, mongodb, database
705
+ TINA4_SWAGGER_TITLE=My API
704
706
  ```
705
707
 
706
708
  ## AI Tool Integration
@@ -42,6 +42,8 @@ cd my-app && tina4 serve
42
42
 
43
43
  Open http://localhost:7146 — your app is running.
44
44
 
45
+ > **Two CLIs:** `tina4` is the cross-language Rust CLI — it scaffolds projects, runs the dev server, and watches files. `tina4python` is the Python package's own CLI for project tasks (`migrate`, `seed`, `generate`, `test`). This guide uses `tina4` to scaffold and run, and `tina4python` for those tasks.
46
+
45
47
  <details>
46
48
  <summary><strong>Without the Tina4 CLI (Docker / CI only)</strong></summary>
47
49
 
@@ -130,7 +132,7 @@ pip install tina4-python[dev-reload] # Hot-patching via jurigged
130
132
  ### 1. Create a project
131
133
 
132
134
  ```bash
133
- tina4python init my-app
135
+ tina4 init python my-app
134
136
  cd my-app
135
137
  ```
136
138
 
@@ -662,13 +664,13 @@ Frond.clear_cache()
662
664
  ## Environment
663
665
 
664
666
  ```bash
665
- SECRET=your-jwt-secret
667
+ TINA4_SECRET=your-jwt-secret
666
668
  TINA4_DATABASE_URL=sqlite:///data/app.db
667
669
  TINA4_DEBUG=true # Enable dev toolbar, error overlay
668
670
  TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
669
671
  TINA4_LOCALE=en # en, fr, af, zh, ja, es
670
- TINA4_SESSION_BACKEND=SessionFileHandler
671
- SWAGGER_TITLE=My API
672
+ TINA4_SESSION_BACKEND=file # file (default), redis, valkey, mongodb, database
673
+ TINA4_SWAGGER_TITLE=My API
672
674
  ```
673
675
 
674
676
  ## AI Tool Integration
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.32"
3
+ version = "3.13.34"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -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.32"
11
+ __version__ = "3.13.34"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -34,13 +34,13 @@ from tina4_python.queue.kafka_backend import KafkaBackend
34
34
  from tina4_python.queue.mongo_backend import MongoBackend
35
35
 
36
36
 
37
- def _resolve_backend(topic: str, backend: str | None, max_retries: int):
37
+ def _resolve_backend(topic: str, backend: str | None, max_retries: int, retry_backoff: int = 0):
38
38
  """Resolve which backend adapter to use."""
39
39
  chosen = backend or os.environ.get("TINA4_QUEUE_BACKEND", "file")
40
40
  chosen = chosen.lower().strip()
41
41
 
42
42
  if chosen in ("file", "default", "lite"):
43
- return LiteBackend(topic, max_retries)
43
+ return LiteBackend(topic, max_retries, retry_backoff)
44
44
  elif chosen == "rabbitmq":
45
45
  return RabbitMQBackend(topic, max_retries)
46
46
  elif chosen == "kafka":
@@ -63,10 +63,13 @@ class Queue:
63
63
  """
64
64
 
65
65
  def __init__(self, topic: str = "default", max_retries: int = 3,
66
- backend: str | None = None):
66
+ backend: str | None = None, retry_backoff: int = 0):
67
67
  self.topic = topic
68
68
  self.max_retries = max_retries
69
- self._backend = _resolve_backend(topic, backend, max_retries)
69
+ # Seconds to wait before a failed job is re-attempted (file backend).
70
+ # Default 0 = retry on the very next pop()/consume() iteration.
71
+ self.retry_backoff = retry_backoff
72
+ self._backend = _resolve_backend(topic, backend, max_retries, retry_backoff)
70
73
 
71
74
  def push(self, data: dict, priority: int = 0, delay_seconds: int = 0):
72
75
  """Add a job to the queue. Returns job ID."""
@@ -208,12 +211,12 @@ class Queue:
208
211
 
209
212
  old_topic = self.topic
210
213
  self.topic = topic
211
- self._backend = _resolve_backend(topic, None, self.max_retries)
214
+ self._backend = _resolve_backend(topic, None, self.max_retries, self.retry_backoff)
212
215
  try:
213
216
  return self.push(data, priority, delay_seconds)
214
217
  finally:
215
218
  self.topic = old_topic
216
- self._backend = _resolve_backend(old_topic, None, self.max_retries)
219
+ self._backend = _resolve_backend(old_topic, None, self.max_retries, self.retry_backoff)
217
220
 
218
221
  def consume(self, topic: str = None, job_id: str = None, poll_interval: float = 1.0,
219
222
  iterations: int = 0, batch_size: int = 1):
@@ -309,6 +312,7 @@ class Queue:
309
312
  data=job_data["data"],
310
313
  priority=job_data.get("priority", 0),
311
314
  attempts=job_data.get("attempts", 0),
315
+ error=job_data.get("error"),
312
316
  )
313
317
  except (json.JSONDecodeError, FileNotFoundError):
314
318
  continue
@@ -20,11 +20,20 @@ class Job:
20
20
  return self.payload
21
21
 
22
22
  def complete(self):
23
- """Mark job as completed."""
23
+ """Mark job as completed. Terminal — the job is done and removed."""
24
24
  self.queue._backend.complete(self)
25
25
 
26
26
  def fail(self, error: str = ""):
27
- """Mark job as failed. Will be retried if attempts < max_retries."""
27
+ """Record a failed attempt.
28
+
29
+ Increments ``attempts``. If the job still has retries left
30
+ (``attempts < max_retries``) it is automatically re-enqueued to the
31
+ pending queue, so the next ``pop()``/``consume()`` picks it up again
32
+ (after the queue's ``retry_backoff`` delay, if any). Once it has been
33
+ attempted ``max_retries`` times it is moved to the dead-letter store,
34
+ where ``queue.dead_letters()`` returns it. No manual ``retry_failed()``
35
+ is required.
36
+ """
28
37
  self.queue._backend.fail(self, error)
29
38
 
30
39
  def reject(self, reason: str = ""):
@@ -30,9 +30,13 @@ class LiteBackend:
30
30
  Each job is stored as a separate .queue-data JSON file.
31
31
  """
32
32
 
33
- def __init__(self, topic: str, max_retries: int):
33
+ def __init__(self, topic: str, max_retries: int, retry_backoff: int = 0):
34
34
  self._topic = topic
35
35
  self._max_retries = max_retries
36
+ # Seconds to delay a job's next attempt when it is automatically
37
+ # re-enqueued by fail(). 0 (the default) means retry immediately —
38
+ # the next pop()/consume() iteration picks it up straight away.
39
+ self._retry_backoff = retry_backoff
36
40
  self._base_path = os.environ.get("TINA4_QUEUE_PATH", "data/queue")
37
41
  self._lock = threading.Lock()
38
42
  self._seq = 0
@@ -74,29 +78,54 @@ class LiteBackend:
74
78
  json.dump(job, f, indent=2, default=str)
75
79
  return job_id
76
80
 
81
+ def _available_candidates(self, now: str) -> list:
82
+ """Return (filename, job_data) for every pending, non-delayed job,
83
+ ordered by the dequeue policy: highest priority first, ties broken
84
+ oldest-first by created_at.
85
+
86
+ The file *name* is no longer the ordering key — the stored
87
+ ``priority`` and ``created_at`` fields are. This makes pop()/consume()
88
+ honour priority instead of being pure FIFO.
89
+ """
90
+ queue_dir = self._queue_dir()
91
+ try:
92
+ filenames = os.listdir(queue_dir)
93
+ except FileNotFoundError:
94
+ return []
95
+
96
+ candidates = []
97
+ for filename in filenames:
98
+ if not filename.endswith(".queue-data"):
99
+ continue
100
+ filepath = os.path.join(queue_dir, filename)
101
+ try:
102
+ with open(filepath) as f:
103
+ job_data = json.load(f)
104
+ except (json.JSONDecodeError, FileNotFoundError):
105
+ continue
106
+ if job_data.get("status") != "pending":
107
+ continue
108
+ if job_data.get("available_at", "") > now:
109
+ continue # still delayed
110
+ candidates.append((filename, job_data))
111
+
112
+ # priority DESC, then created_at ASC (oldest first). created_at is an
113
+ # ISO-8601 string so lexicographic order == chronological order.
114
+ candidates.sort(
115
+ key=lambda item: (
116
+ -int(item[1].get("priority", 0) or 0),
117
+ item[1].get("created_at", "") or "",
118
+ )
119
+ )
120
+ return candidates
121
+
77
122
  def pop(self, queue_ref) -> Job | None:
78
123
  now = _now()
79
124
  queue_dir = self._queue_dir()
80
125
 
81
126
  with self._lock:
82
- try:
83
- files = sorted(f for f in os.listdir(queue_dir) if f.endswith(".queue-data"))
84
- except FileNotFoundError:
85
- return None
86
-
87
- for filename in files:
127
+ for filename, job_data in self._available_candidates(now):
88
128
  filepath = os.path.join(queue_dir, filename)
89
- try:
90
- with open(filepath) as f:
91
- job_data = json.load(f)
92
- except (json.JSONDecodeError, FileNotFoundError):
93
- continue
94
-
95
- if job_data.get("status") != "pending":
96
- continue
97
- if job_data.get("available_at", "") > now:
98
- continue
99
-
100
129
  # Claim the job by deleting the file
101
130
  try:
102
131
  os.unlink(filepath)
@@ -110,37 +139,25 @@ class LiteBackend:
110
139
  data=job_data["data"],
111
140
  priority=job_data.get("priority", 0),
112
141
  attempts=job_data.get("attempts", 0),
142
+ error=job_data.get("error"),
113
143
  )
114
144
 
115
145
  return None
116
146
 
117
147
  def pop_batch(self, count: int, queue_ref) -> list:
118
- """Pop up to count jobs in one operation. Returns partial batch if fewer available."""
148
+ """Pop up to count jobs in one operation, highest priority first.
149
+
150
+ Returns a partial batch if fewer than ``count`` are available.
151
+ """
119
152
  now = _now()
120
153
  queue_dir = self._queue_dir()
121
154
  results = []
122
155
 
123
156
  with self._lock:
124
- try:
125
- files = sorted(f for f in os.listdir(queue_dir) if f.endswith(".queue-data"))
126
- except FileNotFoundError:
127
- return []
128
-
129
- for filename in files:
157
+ for filename, job_data in self._available_candidates(now):
130
158
  if len(results) >= count:
131
159
  break
132
160
  filepath = os.path.join(queue_dir, filename)
133
- try:
134
- with open(filepath) as f:
135
- job_data = json.load(f)
136
- except (json.JSONDecodeError, FileNotFoundError):
137
- continue
138
-
139
- if job_data.get("status") != "pending":
140
- continue
141
- if job_data.get("available_at", "") > now:
142
- continue
143
-
144
161
  try:
145
162
  os.unlink(filepath)
146
163
  except FileNotFoundError:
@@ -153,45 +170,57 @@ class LiteBackend:
153
170
  data=job_data["data"],
154
171
  priority=job_data.get("priority", 0),
155
172
  attempts=job_data.get("attempts", 0),
173
+ error=job_data.get("error"),
156
174
  ))
157
175
 
158
176
  return results
159
177
 
178
+ # Status aliases that live in the failed/ directory (dead-lettered jobs)
179
+ # rather than as pending files in the queue directory.
180
+ _DEAD_STATES = ("failed", "dead", "dead_letter")
181
+
160
182
  def size(self, status: str = "pending") -> int:
161
- queue_dir = self._queue_dir()
183
+ scan_dir = self._failed_dir() if status in self._DEAD_STATES else self._queue_dir()
162
184
  count = 0
163
185
  try:
164
- for filename in os.listdir(queue_dir):
186
+ for filename in os.listdir(scan_dir):
165
187
  if not filename.endswith(".queue-data"):
166
188
  continue
167
- filepath = os.path.join(queue_dir, filename)
189
+ filepath = os.path.join(scan_dir, filename)
168
190
  try:
169
191
  with open(filepath) as f:
170
192
  job_data = json.load(f)
171
- if job_data.get("status") == status:
172
- count += 1
173
193
  except (json.JSONDecodeError, FileNotFoundError):
174
194
  continue
195
+ if status in self._DEAD_STATES:
196
+ # Every file in failed/ is a dead-letter; count them all
197
+ # regardless of the exact stored status string.
198
+ count += 1
199
+ elif job_data.get("status") == status:
200
+ count += 1
175
201
  except FileNotFoundError:
176
202
  pass
177
203
  return count
178
204
 
179
205
  def purge(self, status: str = "completed") -> int:
180
- queue_dir = self._queue_dir()
206
+ scan_dir = self._failed_dir() if status in self._DEAD_STATES else self._queue_dir()
181
207
  count = 0
182
208
  try:
183
- for filename in os.listdir(queue_dir):
209
+ for filename in os.listdir(scan_dir):
184
210
  if not filename.endswith(".queue-data"):
185
211
  continue
186
- filepath = os.path.join(queue_dir, filename)
212
+ filepath = os.path.join(scan_dir, filename)
187
213
  try:
188
214
  with open(filepath) as f:
189
215
  job_data = json.load(f)
190
- if job_data.get("status") == status:
191
- os.unlink(filepath)
192
- count += 1
193
216
  except (json.JSONDecodeError, FileNotFoundError):
194
217
  continue
218
+ if status in self._DEAD_STATES or job_data.get("status") == status:
219
+ try:
220
+ os.unlink(filepath)
221
+ count += 1
222
+ except FileNotFoundError:
223
+ continue
195
224
  except FileNotFoundError:
196
225
  pass
197
226
  return count
@@ -212,6 +241,7 @@ class LiteBackend:
212
241
  if job_data.get("attempts", 0) < limit:
213
242
  job_data["status"] = "pending"
214
243
  job_data["available_at"] = _now()
244
+ job_data["created_at"] = _now()
215
245
  prefix = self._next_prefix()
216
246
  new_path = os.path.join(queue_dir, f"{prefix}_{job_data['id']}.queue-data")
217
247
  with open(new_path, "w") as f:
@@ -225,20 +255,28 @@ class LiteBackend:
225
255
  return count
226
256
 
227
257
  def failed(self) -> list[dict]:
228
- failed_dir = self._failed_dir()
258
+ """Jobs that have failed at least once but are still being retried.
259
+
260
+ Under the auto-retry lifecycle a failed-but-retryable job lives in the
261
+ pending queue (not the dead-letter dir), so this scans the queue dir
262
+ for pending jobs with ``attempts > 0`` that have not yet exhausted
263
+ their retries. Dead-lettered jobs are returned by dead_letters().
264
+ """
265
+ queue_dir = self._queue_dir()
229
266
  results = []
230
267
  try:
231
- for filename in sorted(os.listdir(failed_dir)):
268
+ for filename in sorted(os.listdir(queue_dir)):
232
269
  if not filename.endswith(".queue-data"):
233
270
  continue
234
- filepath = os.path.join(failed_dir, filename)
271
+ filepath = os.path.join(queue_dir, filename)
235
272
  try:
236
273
  with open(filepath) as f:
237
274
  job_data = json.load(f)
238
- if job_data.get("attempts", 0) < self._max_retries:
239
- results.append(job_data)
240
275
  except (json.JSONDecodeError, FileNotFoundError):
241
276
  continue
277
+ attempts = job_data.get("attempts", 0)
278
+ if attempts > 0 and attempts < self._max_retries:
279
+ results.append(job_data)
242
280
  except FileNotFoundError:
243
281
  pass
244
282
  return results
@@ -264,17 +302,22 @@ class LiteBackend:
264
302
  return results
265
303
 
266
304
  def retry_job(self, job_id: str, delay_seconds: int = 0) -> bool:
305
+ """Revive a specific dead-letter job by id back to the pending queue.
306
+
307
+ This is a manual override (Queue.retry(job_id)) — it always revives a
308
+ dead-letter regardless of attempt count, mirroring job.retry(). Returns
309
+ False only if no dead-letter with that id exists.
310
+ """
267
311
  failed_dir = self._failed_dir()
268
312
  queue_dir = self._queue_dir()
269
313
  filepath = os.path.join(failed_dir, f"{job_id}.queue-data")
270
314
  try:
271
315
  with open(filepath) as f:
272
316
  job_data = json.load(f)
273
- if job_data.get("attempts", 0) >= self._max_retries:
274
- return False
275
317
  job_data["status"] = "pending"
276
318
  job_data["error"] = None
277
319
  job_data["attempts"] = job_data.get("attempts", 0) + 1
320
+ job_data["created_at"] = _now()
278
321
  if delay_seconds > 0:
279
322
  job_data["available_at"] = _future(delay_seconds)
280
323
  else:
@@ -305,41 +348,75 @@ class LiteBackend:
305
348
  return count
306
349
 
307
350
  def complete(self, job: Job):
308
- # Job file was already deleted on pop — nothing to do
351
+ # Job file was already deleted on pop — nothing to do. complete() is
352
+ # terminal: the job is done and gone.
309
353
  pass
310
354
 
311
- def fail(self, job: Job, error: str = ""):
312
- job.attempts += 1
355
+ def _requeue(self, job: Job, delay_seconds: int = 0, error: str | None = None):
356
+ """Write the job back to the pending queue (queue dir).
357
+
358
+ Re-enqueued jobs get a fresh ``created_at`` so within a priority tier
359
+ they sort behind jobs that have not yet been attempted. ``attempts``
360
+ already reflects the latest failure count.
361
+ """
362
+ available = _now() if delay_seconds <= 0 else _future(delay_seconds)
313
363
  job_data = {
314
364
  "id": job.id,
315
365
  "topic": job.topic,
316
366
  "data": job.payload,
317
- "status": "failed",
367
+ "status": "pending",
318
368
  "priority": job.priority,
319
369
  "attempts": job.attempts,
320
370
  "error": error,
321
- "failed_at": _now(),
371
+ "available_at": available,
372
+ "created_at": _now(),
322
373
  }
323
- failed_dir = self._failed_dir()
324
- os.makedirs(failed_dir, exist_ok=True)
325
- filepath = os.path.join(failed_dir, f"{job.id}.queue-data")
374
+ prefix = self._next_prefix()
375
+ filepath = os.path.join(self._queue_dir(), f"{prefix}_{job.id}.queue-data")
326
376
  with open(filepath, "w") as f:
327
377
  json.dump(job_data, f, indent=2, default=str)
328
378
 
329
- def retry(self, job: Job, delay_seconds: int = 0):
330
- job.attempts += 1
331
- available = _now() if delay_seconds == 0 else _future(delay_seconds)
379
+ def _dead_letter(self, job: Job, error: str = ""):
380
+ """Move the job to the dead-letter (failed) directory. Terminal until
381
+ a manual retry_failed()/retry() revives it."""
332
382
  job_data = {
333
383
  "id": job.id,
334
384
  "topic": job.topic,
335
385
  "data": job.payload,
336
- "status": "pending",
386
+ "status": "dead",
337
387
  "priority": job.priority,
338
388
  "attempts": job.attempts,
339
- "available_at": available,
340
- "created_at": _now(),
389
+ "error": error,
390
+ "failed_at": _now(),
341
391
  }
342
- prefix = self._next_prefix()
343
- filepath = os.path.join(self._queue_dir(), f"{prefix}_{job.id}.queue-data")
392
+ failed_dir = self._failed_dir()
393
+ os.makedirs(failed_dir, exist_ok=True)
394
+ filepath = os.path.join(failed_dir, f"{job.id}.queue-data")
344
395
  with open(filepath, "w") as f:
345
396
  json.dump(job_data, f, indent=2, default=str)
397
+
398
+ def fail(self, job: Job, error: str = ""):
399
+ """Record a failed attempt.
400
+
401
+ Increments ``attempts``. If the job still has retries left
402
+ (``attempts < max_retries``) it is automatically re-enqueued to the
403
+ pending queue — so the next pop()/consume() iteration picks it up
404
+ again — after an optional ``retry_backoff`` delay. Once it has been
405
+ attempted ``max_retries`` times (``attempts >= max_retries``) it is
406
+ moved to the dead-letter store, where dead_letters() returns it.
407
+ """
408
+ job.attempts += 1
409
+ job.error = error
410
+ if job.attempts < self._max_retries:
411
+ self._requeue(job, delay_seconds=self._retry_backoff, error=error)
412
+ else:
413
+ self._dead_letter(job, error)
414
+
415
+ def retry(self, job: Job, delay_seconds: int = 0):
416
+ """Explicit re-queue requested by the caller (job.retry()).
417
+
418
+ Always re-enqueues regardless of the retry limit — this is a manual
419
+ override, distinct from the automatic fail() path.
420
+ """
421
+ job.attempts += 1
422
+ self._requeue(job, delay_seconds=delay_seconds, error=None)