tina4-python 3.13.40__tar.gz → 3.13.42__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 (158) hide show
  1. {tina4_python-3.13.40 → tina4_python-3.13.42}/PKG-INFO +1 -1
  2. {tina4_python-3.13.40 → tina4_python-3.13.42}/pyproject.toml +1 -1
  3. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/CLAUDE.md +45 -1
  4. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/__init__.py +38 -8
  6. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/lite_backend.py +132 -21
  7. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/mongo_backend.py +8 -2
  8. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue_backends/mongo_backend.py +71 -3
  9. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/swagger/__init__.py +260 -12
  10. {tina4_python-3.13.40 → tina4_python-3.13.42}/.gitignore +0 -0
  11. {tina4_python-3.13.40 → tina4_python-3.13.42}/README.md +0 -0
  12. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/__init__.py +0 -0
  21. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/cache.py +0 -0
  22. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/constants.py +0 -0
  23. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/events.py +0 -0
  24. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/middleware.py +0 -0
  25. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/rate_limiter.py +0 -0
  26. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/response.py +0 -0
  28. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/router.py +0 -0
  29. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/core/server.py +0 -0
  30. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/crud/__init__.py +0 -0
  31. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/__init__.py +0 -0
  32. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/adapter.py +0 -0
  33. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/connection.py +0 -0
  34. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/mssql.py +0 -0
  37. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/mysql.py +0 -0
  38. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/odbc.py +0 -0
  39. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/postgres.py +0 -0
  40. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/database/sqlite.py +0 -0
  41. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/debug/__init__.py +0 -0
  42. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/debug/error_overlay.py +0 -0
  43. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dev_admin/__init__.py +0 -0
  44. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dev_admin/metrics.py +0 -0
  45. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dev_admin/plan.py +0 -0
  46. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dev_admin/project_index.py +0 -0
  47. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/docs.py +0 -0
  48. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/docstore/__init__.py +0 -0
  49. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/dotenv/__init__.py +0 -0
  50. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/env.py +0 -0
  51. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/frond/FROND.md +0 -0
  52. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/frond/__init__.py +0 -0
  53. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/frond/engine.py +0 -0
  54. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/auth/meta.json +0 -0
  55. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  56. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/database/meta.json +0 -0
  57. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  58. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/error-overlay/meta.json +0 -0
  59. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  60. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/orm/meta.json +0 -0
  61. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  62. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  63. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/queue/meta.json +0 -0
  64. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  65. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/rest-api/meta.json +0 -0
  66. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  67. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/templates/meta.json +0 -0
  68. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  69. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  70. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/graphql/__init__.py +0 -0
  71. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/i18n/__init__.py +0 -0
  72. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/mcp/__init__.py +0 -0
  73. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/mcp/protocol.py +0 -0
  74. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/mcp/tools.py +0 -0
  75. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/messenger/__init__.py +0 -0
  76. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/migration/__init__.py +0 -0
  77. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/migration/runner.py +0 -0
  78. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/orm/__init__.py +0 -0
  79. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/orm/fields.py +0 -0
  80. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/orm/model.py +0 -0
  81. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/__feedback/widget.js +0 -0
  82. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/css/tina4.css +0 -0
  83. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/css/tina4.min.css +0 -0
  84. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/favicon.ico +0 -0
  85. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/images/logo.svg +0 -0
  86. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  87. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/frond.js +0 -0
  88. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/frond.min.js +0 -0
  89. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  90. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  91. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/tina4.min.js +0 -0
  92. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/js/tina4js.min.js +0 -0
  93. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/swagger/index.html +0 -0
  94. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  95. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/query_builder/__init__.py +0 -0
  96. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/job.py +0 -0
  97. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/kafka_backend.py +0 -0
  98. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue/rabbitmq_backend.py +0 -0
  99. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue_backends/__init__.py +0 -0
  100. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue_backends/kafka_backend.py +0 -0
  101. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  102. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/__init__.py +0 -0
  103. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  104. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_badges.scss +0 -0
  105. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  106. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_cards.scss +0 -0
  107. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_forms.scss +0 -0
  108. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_grid.scss +0 -0
  109. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_modals.scss +0 -0
  110. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_nav.scss +0 -0
  111. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_reset.scss +0 -0
  112. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_tables.scss +0 -0
  113. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_typography.scss +0 -0
  114. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  115. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_variables.scss +0 -0
  116. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/base.scss +0 -0
  117. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/colors.scss +0 -0
  118. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/scss/tina4css/tina4.scss +0 -0
  119. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/seeder/__init__.py +0 -0
  120. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/service/__init__.py +0 -0
  121. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session/__init__.py +0 -0
  122. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session_handlers/__init__.py +0 -0
  123. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  124. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session_handlers/redis_handler.py +0 -0
  125. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/session_handlers/valkey_handler.py +0 -0
  126. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/components/crud.twig +0 -0
  127. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  128. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  129. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/docker/python/Dockerfile +0 -0
  130. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  131. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/302.twig +0 -0
  132. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/401.twig +0 -0
  133. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/403.twig +0 -0
  134. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/404.twig +0 -0
  135. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/500.twig +0 -0
  136. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/502.twig +0 -0
  137. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/503.twig +0 -0
  138. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/errors/base.twig +0 -0
  139. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/frontend/README.md +0 -0
  140. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/templates/readme.md +0 -0
  141. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/test/__init__.py +0 -0
  142. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/test_client/__init__.py +0 -0
  143. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/validator/__init__.py +0 -0
  156. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/websocket/__init__.py +0 -0
  157. {tina4_python-3.13.40 → tina4_python-3.13.42}/tina4_python/websocket/backplane.py +0 -0
  158. {tina4_python-3.13.40 → tina4_python-3.13.42}/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.40
3
+ Version: 3.13.42
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.40"
3
+ version = "3.13.42"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -1145,9 +1145,14 @@ for job in queue.consume():
1145
1145
  ```python
1146
1146
  queue = Queue(topic="tasks", max_retries=3)
1147
1147
 
1148
+ # Reservation / visibility timeout (seconds): a popped job is reserved this long;
1149
+ # if the consumer dies before complete()/fail() the next pop() reclaims it
1150
+ # (at-least-once delivery). Default 300; env TINA4_QUEUE_VISIBILITY_TIMEOUT; <= 0 disables.
1151
+ queue = Queue(topic="tasks", visibility_timeout=300)
1152
+
1148
1153
  # Check queue size
1149
1154
  queue.size() # pending jobs
1150
- queue.size("reserved") # currently processing
1155
+ queue.size("reserved") # currently reserved (in-flight) jobs
1151
1156
 
1152
1157
  # Retry failed jobs (under max_retries limit)
1153
1158
  queue.retry_failed()
@@ -1159,6 +1164,14 @@ dead = queue.dead_letters()
1159
1164
  queue.purge("completed")
1160
1165
  ```
1161
1166
 
1167
+ **At-least-once delivery (file + MongoDB backends).** A popped job is held as a
1168
+ reservation for `visibility_timeout` seconds. If the consumer crashes, OOMs, or
1169
+ is evicted before calling `job.complete()` / `job.fail()`, the next `pop()`
1170
+ reclaims the abandoned reservation: it increments `attempts` and re-enqueues the
1171
+ job, or dead-letters it once it has hit `max_retries`. A dead consumer therefore
1172
+ never strands a job. RabbitMQ and Kafka delegate redelivery to the broker (the
1173
+ framework timeout does not apply there).
1174
+
1162
1175
  ### When to use queues
1163
1176
  - Sending emails or SMS
1164
1177
  - Generating PDFs/reports
@@ -1644,6 +1657,37 @@ TINA4_SWAGGER_LICENSE= # SPDX license name (e.g. MIT) for info.licens
1644
1657
  TINA4_SWAGGER_SERVERS= # comma-separated server URLs for the OpenAPI servers[] block; falls back to SWAGGER_DEV_URL
1645
1658
  TINA4_SWAGGER_UI_CDN= # base URL for the Swagger UI assets (default jsdelivr); point at a self-hosted mirror for air-gapped use
1646
1659
  SWAGGER_DEV_URL=http://localhost:7145 # single dev-server URL (used when TINA4_SWAGGER_SERVERS is unset)
1660
+ TINA4_SWAGGER_OPENAPI=3.0.3 # OpenAPI version: 3.0.3 (default) or 3.1 (-> emits 3.1.0)
1661
+ TINA4_SWAGGER_BEARER_FORMAT=JWT # bearerFormat on the built-in bearerAuth scheme (e.g. opaque for sk_live_ keys)
1662
+ TINA4_SWAGGER_API_KEY_NAME= # if set, emit an apiKeyAuth scheme with this header/query name (e.g. X-Api-Key)
1663
+ TINA4_SWAGGER_API_KEY_IN=header # where the apiKey lives: header (default) | query | cookie
1664
+ TINA4_SWAGGER_DEFAULT_SCHEME=bearerAuth # scheme secured routes use when no @security is set
1665
+ TINA4_SWAGGER_INCLUDE= # comma-separated path prefixes to include (allow-list; only these documented)
1666
+ TINA4_SWAGGER_EXCLUDE= # comma-separated path prefixes to drop (/swagger + /__dev are always excluded)
1667
+ ```
1668
+
1669
+ **Per-route security + reusable schemas (v3.13.42).** Configure named security
1670
+ schemes (configurable `bearerFormat`, an optional `apiKey` scheme, or register
1671
+ arbitrary schemes incl. `oauth2` with scopes via `Swagger.add_security_scheme(name, def)`),
1672
+ then declare them per route with `@security`:
1673
+
1674
+ ```python
1675
+ from tina4_python.swagger import security, request_schema, response_schema, Swagger
1676
+
1677
+ @security("oauth2", scopes=["read:users"]) # scopes kept only for oauth2/openIdConnect
1678
+ @get("/api/v1/users")
1679
+ async def list_users(request, response): ...
1680
+
1681
+ @security("public") # explicitly public (overrides write-secure-by-default)
1682
+ @post("/api/v1/webhook")
1683
+ async def webhook(request, response): ...
1684
+
1685
+ # Reusable component schemas referenced by $ref (beyond ORM-model auto-schemas):
1686
+ Swagger.add_schema("CreateUser", {"type": "object", "properties": {"email": {"type": "string"}}})
1687
+ @request_schema("CreateUser")
1688
+ @response_schema("User", status=200)
1689
+ @post("/api/v1/users")
1690
+ async def create_user(request, response): ...
1647
1691
  ```
1648
1692
 
1649
1693
  The spec is OpenAPI 3.0.3. ORM models registered via AutoCrud become reusable
@@ -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.40"
11
+ __version__ = "3.13.42"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -17,6 +17,10 @@ Environment variables:
17
17
  TINA4_QUEUE_BACKEND — 'file' (default), 'rabbitmq', 'kafka', or 'mongodb'
18
18
  TINA4_QUEUE_URL — connection URL for rabbitmq/kafka
19
19
  TINA4_QUEUE_PATH — file backend storage path (default: data/queue)
20
+ TINA4_QUEUE_VISIBILITY_TIMEOUT — seconds a popped job stays reserved before
21
+ a dead consumer's job is reclaimed (default 300;
22
+ <= 0 disables reclaim). File + MongoDB backends only;
23
+ RabbitMQ/Kafka delegate visibility to the broker.
20
24
  TINA4_RABBITMQ_HOST — RabbitMQ host (default: localhost)
21
25
  TINA4_KAFKA_BROKERS — Kafka brokers (default: localhost:9092)
22
26
  TINA4_MONGO_HOST — MongoDB host (default: localhost)
@@ -24,7 +28,6 @@ Environment variables:
24
28
  import json
25
29
  import os
26
30
  import time
27
- import threading
28
31
  from datetime import datetime, timezone
29
32
 
30
33
  from tina4_python.queue.job import Job
@@ -34,19 +37,31 @@ from tina4_python.queue.kafka_backend import KafkaBackend
34
37
  from tina4_python.queue.mongo_backend import MongoBackend
35
38
 
36
39
 
37
- def _resolve_backend(topic: str, backend: str | None, max_retries: int, retry_backoff: int = 0):
40
+ def _default_visibility_timeout() -> float:
41
+ """Reservation/visibility timeout in seconds, from env (default 300 = 5 min)."""
42
+ try:
43
+ return float(os.environ.get("TINA4_QUEUE_VISIBILITY_TIMEOUT", "300"))
44
+ except (TypeError, ValueError):
45
+ return 300.0
46
+
47
+
48
+ def _resolve_backend(topic: str, backend: str | None, max_retries: int,
49
+ retry_backoff: int = 0, visibility_timeout: float = 300.0):
38
50
  """Resolve which backend adapter to use."""
39
51
  chosen = backend or os.environ.get("TINA4_QUEUE_BACKEND", "file")
40
52
  chosen = chosen.lower().strip()
41
53
 
42
54
  if chosen in ("file", "default", "lite"):
43
- return LiteBackend(topic, max_retries, retry_backoff)
55
+ return LiteBackend(topic, max_retries, retry_backoff, visibility_timeout)
44
56
  elif chosen == "rabbitmq":
57
+ # Broker manages visibility/redelivery (unacked messages requeue on
58
+ # channel close) — the framework timeout is accepted but not used.
45
59
  return RabbitMQBackend(topic, max_retries)
46
60
  elif chosen == "kafka":
61
+ # Consumer-group offsets manage redelivery — framework timeout N/A.
47
62
  return KafkaBackend(topic, max_retries)
48
63
  elif chosen in ("mongodb", "mongo"):
49
- return MongoBackend(topic, max_retries)
64
+ return MongoBackend(topic, max_retries, visibility_timeout)
50
65
  else:
51
66
  raise ValueError(f"Unknown queue backend: {chosen!r}. Use 'file', 'rabbitmq', 'kafka', or 'mongodb'.")
52
67
 
@@ -63,13 +78,23 @@ class Queue:
63
78
  """
64
79
 
65
80
  def __init__(self, topic: str = "default", max_retries: int = 3,
66
- backend: str | None = None, retry_backoff: int = 0):
81
+ backend: str | None = None, retry_backoff: int = 0,
82
+ visibility_timeout: float | None = None):
67
83
  self.topic = topic
68
84
  self.max_retries = max_retries
69
85
  # Seconds to wait before a failed job is re-attempted (file backend).
70
86
  # Default 0 = retry on the very next pop()/consume() iteration.
71
87
  self.retry_backoff = retry_backoff
72
- self._backend = _resolve_backend(topic, backend, max_retries, retry_backoff)
88
+ # Reservation/visibility timeout (seconds). A popped job is reserved for
89
+ # this long; if the consumer dies before complete()/fail() the next
90
+ # pop() reclaims it (at-least-once delivery). Falls back to
91
+ # TINA4_QUEUE_VISIBILITY_TIMEOUT, else 300 (5 min). <= 0 disables reclaim.
92
+ self.visibility_timeout = (
93
+ visibility_timeout if visibility_timeout is not None
94
+ else _default_visibility_timeout()
95
+ )
96
+ self._backend = _resolve_backend(topic, backend, max_retries, retry_backoff,
97
+ self.visibility_timeout)
73
98
 
74
99
  def push(self, data: dict, priority: int = 0, delay_seconds: int = 0):
75
100
  """Add a job to the queue. Returns job ID."""
@@ -211,12 +236,14 @@ class Queue:
211
236
 
212
237
  old_topic = self.topic
213
238
  self.topic = topic
214
- self._backend = _resolve_backend(topic, None, self.max_retries, self.retry_backoff)
239
+ self._backend = _resolve_backend(topic, None, self.max_retries, self.retry_backoff,
240
+ self.visibility_timeout)
215
241
  try:
216
242
  return self.push(data, priority, delay_seconds)
217
243
  finally:
218
244
  self.topic = old_topic
219
- self._backend = _resolve_backend(old_topic, None, self.max_retries, self.retry_backoff)
245
+ self._backend = _resolve_backend(old_topic, None, self.max_retries, self.retry_backoff,
246
+ self.visibility_timeout)
220
247
 
221
248
  def consume(self, topic: str = None, job_id: str = None, poll_interval: float = 1.0,
222
249
  iterations: int = 0, batch_size: int = 1):
@@ -305,6 +332,9 @@ class Queue:
305
332
  with open(filepath) as f:
306
333
  job_data = json.load(f)
307
334
  if job_data.get("id") == job_id and job_data.get("status") == "pending":
335
+ # Reserve (so a dead consumer's job is reclaimable) then
336
+ # claim the pending file — mirrors LiteBackend.pop().
337
+ self._backend._write_reserved(job_data)
308
338
  os.unlink(filepath)
309
339
  return Job(
310
340
  queue=self, job_id=job_data["id"],
@@ -30,13 +30,22 @@ 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, retry_backoff: int = 0):
33
+ def __init__(self, topic: str, max_retries: int, retry_backoff: int = 0,
34
+ visibility_timeout: float = 300.0):
34
35
  self._topic = topic
35
36
  self._max_retries = max_retries
36
37
  # Seconds to delay a job's next attempt when it is automatically
37
38
  # re-enqueued by fail(). 0 (the default) means retry immediately —
38
39
  # the next pop()/consume() iteration picks it up straight away.
39
40
  self._retry_backoff = retry_backoff
41
+ # Reservation/visibility timeout (seconds). When a job is popped it is
42
+ # held in reserved/ with available_at = now + visibility_timeout. If the
43
+ # consumer dies before complete()/fail() (crash, OOM, k8s eviction), the
44
+ # next pop() reclaims it once the window expires — incrementing attempts
45
+ # and re-enqueuing, or dead-lettering past max_retries. <= 0 disables the
46
+ # reclaim (a reservation then lasts until the consumer acks — the old
47
+ # at-most-once behaviour).
48
+ self._visibility_timeout = visibility_timeout
40
49
  self._base_path = os.environ.get("TINA4_QUEUE_PATH", "data/queue")
41
50
  self._lock = threading.Lock()
42
51
  self._seq = 0
@@ -47,6 +56,7 @@ class LiteBackend:
47
56
  failed_dir = os.path.join(queue_dir, "failed")
48
57
  os.makedirs(queue_dir, exist_ok=True)
49
58
  os.makedirs(failed_dir, exist_ok=True)
59
+ os.makedirs(self._reserved_dir(), exist_ok=True)
50
60
 
51
61
  def _queue_dir(self) -> str:
52
62
  return os.path.join(self._base_path, self._topic)
@@ -54,6 +64,12 @@ class LiteBackend:
54
64
  def _failed_dir(self) -> str:
55
65
  return os.path.join(self._base_path, self._topic, "failed")
56
66
 
67
+ def _reserved_dir(self) -> str:
68
+ return os.path.join(self._base_path, self._topic, "reserved")
69
+
70
+ def _reserved_path(self, job_id: str) -> str:
71
+ return os.path.join(self._reserved_dir(), f"{job_id}.queue-data")
72
+
57
73
  def _next_prefix(self) -> str:
58
74
  self._seq += 1
59
75
  return f"{int(time.time() * 1000)}-{self._seq:06d}"
@@ -119,14 +135,92 @@ class LiteBackend:
119
135
  )
120
136
  return candidates
121
137
 
122
- def pop(self, queue_ref) -> Job | None:
138
+ def _write_reserved(self, job_data: dict, attempts: int | None = None):
139
+ """Persist a reservation record so a dead consumer's job is reclaimable.
140
+
141
+ Stores reserved_at + available_at = now + visibility_timeout. The next
142
+ pop() reclaims this job once available_at has passed (see
143
+ _reclaim_expired). complete()/fail()/retry() delete the record.
144
+ """
123
145
  now = _now()
146
+ vt = self._visibility_timeout or 0
147
+ record = {
148
+ "id": job_data["id"],
149
+ "topic": job_data.get("topic", self._topic),
150
+ "data": job_data.get("data", {}),
151
+ "status": "reserved",
152
+ "priority": job_data.get("priority", 0),
153
+ "attempts": job_data.get("attempts", 0) if attempts is None else attempts,
154
+ "error": job_data.get("error"),
155
+ "reserved_at": now,
156
+ "available_at": _future(vt) if vt > 0 else now,
157
+ "created_at": job_data.get("created_at", now),
158
+ }
159
+ os.makedirs(self._reserved_dir(), exist_ok=True)
160
+ with open(self._reserved_path(record["id"]), "w") as f:
161
+ json.dump(record, f, indent=2, default=str)
162
+
163
+ def _reclaim_expired(self, now: str):
164
+ """Return expired reservations to the queue (at-least-once delivery).
165
+
166
+ A reserved job whose ``available_at <= now`` means its consumer never
167
+ acknowledged in time (crash / OOM / pod eviction). Increment attempts
168
+ and either re-enqueue it (so the next pop picks it up) or dead-letter it
169
+ once it has hit max_retries. Disabled when visibility_timeout <= 0.
170
+ """
171
+ if not self._visibility_timeout or self._visibility_timeout <= 0:
172
+ return
173
+ reserved_dir = self._reserved_dir()
174
+ try:
175
+ filenames = os.listdir(reserved_dir)
176
+ except FileNotFoundError:
177
+ return
178
+ for filename in filenames:
179
+ if not filename.endswith(".queue-data"):
180
+ continue
181
+ filepath = os.path.join(reserved_dir, filename)
182
+ try:
183
+ with open(filepath) as f:
184
+ job_data = json.load(f)
185
+ except (json.JSONDecodeError, FileNotFoundError):
186
+ continue
187
+ if job_data.get("available_at", "") > now:
188
+ continue # reservation still valid
189
+ # Atomically claim the expired reservation by deleting its file.
190
+ try:
191
+ os.unlink(filepath)
192
+ except FileNotFoundError:
193
+ continue # another worker reclaimed it first
194
+ attempts = job_data.get("attempts", 0) + 1
195
+ error = ("reservation timed out — consumer did not acknowledge "
196
+ "within the visibility timeout")
197
+ job = Job(
198
+ queue=None,
199
+ job_id=job_data["id"],
200
+ topic=job_data.get("topic", self._topic),
201
+ data=job_data.get("data", {}),
202
+ priority=job_data.get("priority", 0),
203
+ attempts=attempts,
204
+ error=error,
205
+ )
206
+ if attempts >= self._max_retries:
207
+ self._dead_letter(job, error)
208
+ else:
209
+ self._requeue(job, delay_seconds=0, error=error)
210
+
211
+ def pop(self, queue_ref) -> Job | None:
124
212
  queue_dir = self._queue_dir()
125
213
 
126
214
  with self._lock:
215
+ # First return any reservations whose consumer died mid-flight.
216
+ self._reclaim_expired(_now())
217
+ now = _now()
127
218
  for filename, job_data in self._available_candidates(now):
128
219
  filepath = os.path.join(queue_dir, filename)
129
- # Claim the job by deleting the file
220
+ # Write the reservation BEFORE claiming the pending file, so a
221
+ # crash between claim and reserve can never strand the job.
222
+ # Only the worker that wins the unlink owns — and returns — it.
223
+ self._write_reserved(job_data)
130
224
  try:
131
225
  os.unlink(filepath)
132
226
  except FileNotFoundError:
@@ -149,15 +243,17 @@ class LiteBackend:
149
243
 
150
244
  Returns a partial batch if fewer than ``count`` are available.
151
245
  """
152
- now = _now()
153
246
  queue_dir = self._queue_dir()
154
247
  results = []
155
248
 
156
249
  with self._lock:
250
+ self._reclaim_expired(_now())
251
+ now = _now()
157
252
  for filename, job_data in self._available_candidates(now):
158
253
  if len(results) >= count:
159
254
  break
160
255
  filepath = os.path.join(queue_dir, filename)
256
+ self._write_reserved(job_data)
161
257
  try:
162
258
  os.unlink(filepath)
163
259
  except FileNotFoundError:
@@ -180,7 +276,12 @@ class LiteBackend:
180
276
  _DEAD_STATES = ("failed", "dead", "dead_letter")
181
277
 
182
278
  def size(self, status: str = "pending") -> int:
183
- scan_dir = self._failed_dir() if status in self._DEAD_STATES else self._queue_dir()
279
+ if status == "reserved":
280
+ scan_dir = self._reserved_dir()
281
+ elif status in self._DEAD_STATES:
282
+ scan_dir = self._failed_dir()
283
+ else:
284
+ scan_dir = self._queue_dir()
184
285
  count = 0
185
286
  try:
186
287
  for filename in os.listdir(scan_dir):
@@ -192,9 +293,9 @@ class LiteBackend:
192
293
  job_data = json.load(f)
193
294
  except (json.JSONDecodeError, FileNotFoundError):
194
295
  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.
296
+ if status in self._DEAD_STATES or status == "reserved":
297
+ # Every file in the failed/ (or reserved/) dir matches the
298
+ # requested status; count them all.
198
299
  count += 1
199
300
  elif job_data.get("status") == status:
200
301
  count += 1
@@ -332,25 +433,32 @@ class LiteBackend:
332
433
  return False
333
434
 
334
435
  def clear(self) -> int:
335
- queue_dir = self._queue_dir()
336
436
  count = 0
437
+ for scan_dir in (self._queue_dir(), self._reserved_dir()):
438
+ try:
439
+ for filename in os.listdir(scan_dir):
440
+ if not filename.endswith(".queue-data"):
441
+ continue
442
+ try:
443
+ os.unlink(os.path.join(scan_dir, filename))
444
+ count += 1
445
+ except FileNotFoundError:
446
+ continue
447
+ except FileNotFoundError:
448
+ pass
449
+ return count
450
+
451
+ def _clear_reservation(self, job_id: str):
452
+ """Delete a job's reservation record (best-effort)."""
337
453
  try:
338
- for filename in os.listdir(queue_dir):
339
- if not filename.endswith(".queue-data"):
340
- continue
341
- try:
342
- os.unlink(os.path.join(queue_dir, filename))
343
- count += 1
344
- except FileNotFoundError:
345
- continue
454
+ os.unlink(self._reserved_path(job_id))
346
455
  except FileNotFoundError:
347
456
  pass
348
- return count
349
457
 
350
458
  def complete(self, job: Job):
351
- # Job file was already deleted on pop nothing to do. complete() is
352
- # terminal: the job is done and gone.
353
- pass
459
+ # The pending file was claimed on pop and a reservation record written;
460
+ # complete() is terminal, so drop the reservation. The job is done.
461
+ self._clear_reservation(job.id)
354
462
 
355
463
  def _requeue(self, job: Job, delay_seconds: int = 0, error: str | None = None):
356
464
  """Write the job back to the pending queue (queue dir).
@@ -405,6 +513,8 @@ class LiteBackend:
405
513
  attempted ``max_retries`` times (``attempts >= max_retries``) it is
406
514
  moved to the dead-letter store, where dead_letters() returns it.
407
515
  """
516
+ # Clear the reservation — the consumer acknowledged (with a failure).
517
+ self._clear_reservation(job.id)
408
518
  job.attempts += 1
409
519
  job.error = error
410
520
  if job.attempts < self._max_retries:
@@ -418,5 +528,6 @@ class LiteBackend:
418
528
  Always re-enqueues regardless of the retry limit — this is a manual
419
529
  override, distinct from the automatic fail() path.
420
530
  """
531
+ self._clear_reservation(job.id)
421
532
  job.attempts += 1
422
533
  self._requeue(job, delay_seconds=delay_seconds, error=None)
@@ -23,11 +23,11 @@ def _future(seconds: int) -> str:
23
23
  class MongoBackend:
24
24
  """Backend adapter wrapping MongoBackend for the unified Queue API."""
25
25
 
26
- def __init__(self, topic: str, max_retries: int):
26
+ def __init__(self, topic: str, max_retries: int, visibility_timeout: float = 300.0):
27
27
  from tina4_python.queue_backends import MongoConnector as _MongoBackend
28
28
 
29
29
  url = os.environ.get("TINA4_QUEUE_URL", "")
30
- config = {}
30
+ config = {"visibility_timeout": visibility_timeout}
31
31
  if url:
32
32
  config["uri"] = url
33
33
  self._backend = _MongoBackend(**config)
@@ -39,6 +39,12 @@ class MongoBackend:
39
39
  return self._backend.enqueue(self._topic, msg)
40
40
 
41
41
  def pop(self, queue_ref) -> Job | None:
42
+ # Reclaim any reservations whose consumer died before acking, then take
43
+ # the next available message (at-least-once delivery).
44
+ try:
45
+ self._backend.reclaim_expired(self._topic, self._max_retries)
46
+ except AttributeError:
47
+ pass # older connector without reclaim support
42
48
  result = self._backend.dequeue(self._topic)
43
49
  if result is None:
44
50
  return None
@@ -25,7 +25,6 @@ Document schema:
25
25
  completed_at: str | None,
26
26
  }
27
27
  """
28
- import json
29
28
  import os
30
29
  import uuid
31
30
  from datetime import datetime, timezone
@@ -47,6 +46,16 @@ class MongoConnector:
47
46
  self._collection_name = config.get(
48
47
  "collection", os.environ.get("TINA4_MONGO_COLLECTION", "tina4_queue")
49
48
  )
49
+ # Reservation/visibility timeout (seconds): a dequeued message is held
50
+ # reserved with available_at = now + timeout; reclaim_expired() returns
51
+ # it once that passes (consumer died mid-flight). <= 0 disables reclaim.
52
+ try:
53
+ self._visibility_timeout = float(config.get(
54
+ "visibility_timeout",
55
+ os.environ.get("TINA4_QUEUE_VISIBILITY_TIMEOUT", "300"),
56
+ ))
57
+ except (TypeError, ValueError):
58
+ self._visibility_timeout = 300.0
50
59
 
51
60
  self._pymongo = None
52
61
  self._client = None
@@ -105,7 +114,13 @@ class MongoConnector:
105
114
  return msg_id
106
115
 
107
116
  def dequeue(self, topic: str) -> dict | None:
108
- """Atomically claim the next available message. Returns message dict or None."""
117
+ """Atomically claim the next available message. Returns message dict or None.
118
+
119
+ The claim advances ``available_at`` to ``now + visibility_timeout`` and
120
+ records ``reserved_at`` so reclaim_expired() can return the job if the
121
+ consumer dies before acknowledge()/reject(). This is the fix for the
122
+ "reserved forever" bug — previously available_at was left unchanged.
123
+ """
109
124
  self._ensure_connected()
110
125
  now = _now()
111
126
 
@@ -115,7 +130,11 @@ class MongoConnector:
115
130
  "status": "pending",
116
131
  "available_at": {"$lte": now},
117
132
  },
118
- {"$set": {"status": "reserved"}},
133
+ {"$set": {
134
+ "status": "reserved",
135
+ "reserved_at": now,
136
+ "available_at": _future(self._visibility_timeout),
137
+ }},
119
138
  sort=[("priority", self._pymongo.DESCENDING), ("created_at", self._pymongo.ASCENDING)],
120
139
  return_document=self._pymongo.ReturnDocument.AFTER,
121
140
  )
@@ -126,6 +145,50 @@ class MongoConnector:
126
145
  result["id"] = doc["_id"]
127
146
  return result
128
147
 
148
+ def reclaim_expired(self, topic: str, max_retries: int) -> int:
149
+ """Return reservations whose visibility window expired (at-least-once).
150
+
151
+ A message left ``reserved`` with ``available_at <= now`` had a consumer
152
+ die before acknowledging. Each is atomically flipped back to ``pending``
153
+ with ``attempts`` incremented (so the next dequeue re-delivers it); once
154
+ ``attempts >= max_retries`` it is dead-lettered instead. Returns the
155
+ number reclaimed. Disabled when visibility_timeout <= 0.
156
+ """
157
+ if not self._visibility_timeout or self._visibility_timeout <= 0:
158
+ return 0
159
+ self._ensure_connected()
160
+ reclaimed = 0
161
+ while True:
162
+ now = _now()
163
+ doc = self._collection.find_one_and_update(
164
+ {
165
+ "topic": topic,
166
+ "status": "reserved",
167
+ "available_at": {"$lte": now},
168
+ },
169
+ {"$set": {"status": "pending", "available_at": now, "reserved_at": None},
170
+ "$inc": {"attempts": 1}},
171
+ sort=[("available_at", self._pymongo.ASCENDING)],
172
+ return_document=self._pymongo.ReturnDocument.AFTER,
173
+ )
174
+ if doc is None:
175
+ break
176
+ reclaimed += 1
177
+ if doc.get("attempts", 0) >= max_retries:
178
+ # Out of retries — move it to the dead-letter queue and remove
179
+ # the original so it is not re-delivered.
180
+ payload = doc.get("data", {})
181
+ self.dead_letter(topic, {
182
+ "id": doc["_id"],
183
+ "payload": payload,
184
+ "priority": doc.get("priority", 0),
185
+ "attempts": doc.get("attempts", 0),
186
+ "error": "reservation timed out — consumer did not "
187
+ "acknowledge within the visibility timeout",
188
+ })
189
+ self._collection.delete_one({"_id": doc["_id"], "topic": topic})
190
+ return reclaimed
191
+
129
192
  def acknowledge(self, topic: str, message_id: str):
130
193
  """Acknowledge a message as processed."""
131
194
  self._ensure_connected()
@@ -211,5 +274,10 @@ class MongoConnector:
211
274
  def _now() -> str:
212
275
  return datetime.now(timezone.utc).isoformat()
213
276
 
277
+
278
+ def _future(seconds: float) -> str:
279
+ import time
280
+ return datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat()
281
+
214
282
  # Backwards-compatible alias — external code and tests may use the old name.
215
283
  MongoBackend = MongoConnector