tina4-python 3.13.92__tar.gz → 3.13.94__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 (173) hide show
  1. {tina4_python-3.13.92 → tina4_python-3.13.94}/PKG-INFO +2 -2
  2. {tina4_python-3.13.92 → tina4_python-3.13.94}/README.md +1 -1
  3. {tina4_python-3.13.92 → tina4_python-3.13.94}/pyproject.toml +1 -1
  4. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/CLAUDE.md +9 -2
  5. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/__init__.py +9 -1
  6. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/connection.py +110 -5
  7. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/engine.py +68 -8
  8. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/messenger/__init__.py +89 -27
  9. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue_backends/kafka_backend.py +246 -63
  10. {tina4_python-3.13.92 → tina4_python-3.13.94}/.gitignore +0 -0
  11. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/HtmlElement.py +0 -0
  12. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/Testing.py +0 -0
  13. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/ai/__init__.py +0 -0
  14. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/api/__init__.py +0 -0
  15. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/auth/__init__.py +0 -0
  16. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/cache/__init__.py +0 -0
  17. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/cli/__init__.py +0 -0
  18. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/container/__init__.py +0 -0
  19. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/context/__init__.py +0 -0
  20. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/context/chunker.py +0 -0
  21. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/__init__.py +0 -0
  22. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/cache.py +0 -0
  23. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/constants.py +0 -0
  24. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/events.py +0 -0
  25. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/middleware.py +0 -0
  26. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/rate_limiter.py +0 -0
  27. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/request.py +0 -0
  28. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/response.py +0 -0
  29. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/router.py +0 -0
  30. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/server.py +0 -0
  31. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/crud/__init__.py +0 -0
  32. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/__init__.py +0 -0
  33. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/adapter.py +0 -0
  34. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/mssql.py +0 -0
  37. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/mysql.py +0 -0
  38. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/odbc.py +0 -0
  39. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/postgres.py +0 -0
  40. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/sqlite.py +0 -0
  41. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/debug/__init__.py +0 -0
  42. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/debug/error_overlay.py +0 -0
  43. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/__init__.py +0 -0
  44. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/metrics.py +0 -0
  45. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/metrics_engine.py +0 -0
  46. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/plan.py +0 -0
  47. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/project_index.py +0 -0
  48. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/docs.py +0 -0
  49. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/docstore/__init__.py +0 -0
  50. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dotenv/__init__.py +0 -0
  51. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/env.py +0 -0
  52. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/FROND.md +0 -0
  53. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/__init__.py +0 -0
  54. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/compiler.py +0 -0
  55. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/parser.py +0 -0
  56. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/auth/meta.json +0 -0
  57. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  58. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/database/meta.json +0 -0
  59. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  60. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/error-overlay/meta.json +0 -0
  61. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  62. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/orm/meta.json +0 -0
  63. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  64. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  65. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/queue/meta.json +0 -0
  66. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  67. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/rest-api/meta.json +0 -0
  68. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  69. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/templates/meta.json +0 -0
  70. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  71. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  72. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/graphql/__init__.py +0 -0
  73. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/i18n/__init__.py +0 -0
  74. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mcp/__init__.py +0 -0
  75. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mcp/protocol.py +0 -0
  76. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mcp/tools.py +0 -0
  77. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/migration/__init__.py +0 -0
  78. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/migration/runner.py +0 -0
  79. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mqtt/__init__.py +0 -0
  80. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mqtt/message.py +0 -0
  81. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/orm/__init__.py +0 -0
  82. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/orm/fields.py +0 -0
  83. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/orm/model.py +0 -0
  84. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/__feedback/widget.js +0 -0
  85. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/css/tina4.css +0 -0
  86. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/css/tina4.min.css +0 -0
  87. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/favicon.ico +0 -0
  88. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/images/logo.svg +0 -0
  89. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  90. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/frond.js +0 -0
  91. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/frond.min.js +0 -0
  92. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  93. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/tina4.min.js +0 -0
  94. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/tina4js.min.js +0 -0
  95. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/swagger/index.html +0 -0
  96. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  97. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/query_builder/__init__.py +0 -0
  98. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/__init__.py +0 -0
  99. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/job.py +0 -0
  100. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/kafka_backend.py +0 -0
  101. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/lite_backend.py +0 -0
  102. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/mongo_backend.py +0 -0
  103. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/rabbitmq_backend.py +0 -0
  104. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue_backends/__init__.py +0 -0
  105. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue_backends/mongo_backend.py +0 -0
  106. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  107. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/__init__.py +0 -0
  108. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/Attachment.py +0 -0
  109. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/Channel.py +0 -0
  110. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/ChannelMember.py +0 -0
  111. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/Message.py +0 -0
  112. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/Workspace.py +0 -0
  113. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/__init__.py +0 -0
  114. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/storage.py +0 -0
  115. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/__init__.py +0 -0
  116. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  117. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_badges.scss +0 -0
  118. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  119. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_cards.scss +0 -0
  120. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_forms.scss +0 -0
  121. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_grid.scss +0 -0
  122. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_modals.scss +0 -0
  123. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_nav.scss +0 -0
  124. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_pagination.scss +0 -0
  125. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_reset.scss +0 -0
  126. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_tables.scss +0 -0
  127. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_typography.scss +0 -0
  128. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  129. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_variables.scss +0 -0
  130. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/base.scss +0 -0
  131. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/colors.scss +0 -0
  132. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/tina4.scss +0 -0
  133. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/seeder/__init__.py +0 -0
  134. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/service/__init__.py +0 -0
  135. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session/__init__.py +0 -0
  136. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session_handlers/__init__.py +0 -0
  137. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  138. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session_handlers/redis_handler.py +0 -0
  139. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session_handlers/valkey_handler.py +0 -0
  140. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/swagger/__init__.py +0 -0
  141. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/components/crud.twig +0 -0
  142. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  143. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  144. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/docker/python/Dockerfile +0 -0
  145. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  146. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/302.twig +0 -0
  147. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/401.twig +0 -0
  148. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/403.twig +0 -0
  149. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/404.twig +0 -0
  150. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/500.twig +0 -0
  151. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/502.twig +0 -0
  152. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/503.twig +0 -0
  153. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/base.twig +0 -0
  154. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/frontend/README.md +0 -0
  155. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/readme.md +0 -0
  156. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/test/__init__.py +0 -0
  157. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/test_client/__init__.py +0 -0
  158. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  161. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  162. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  163. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  164. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  165. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  166. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  167. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  168. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  169. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  170. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/validator/__init__.py +0 -0
  171. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/websocket/__init__.py +0 -0
  172. {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/websocket/backplane.py +0 -0
  173. {tina4_python-3.13.92 → tina4_python-3.13.94}/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.92
3
+ Version: 3.13.94
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
@@ -742,7 +742,7 @@ Run benchmarks locally: `python benchmarks/benchmark.py --python`
742
742
 
743
743
  ## Cross-Framework Parity
744
744
 
745
- Tina4 ships identical features across four languages: same architecture, same conventions, same 97 features:
745
+ Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
746
746
 
747
747
  | | Python | PHP | Ruby | Node.js |
748
748
  |---|--------|-----|------|---------|
@@ -710,7 +710,7 @@ Run benchmarks locally: `python benchmarks/benchmark.py --python`
710
710
 
711
711
  ## Cross-Framework Parity
712
712
 
713
- Tina4 ships identical features across four languages: same architecture, same conventions, same 97 features:
713
+ Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
714
714
 
715
715
  | | Python | PHP | Ruby | Node.js |
716
716
  |---|--------|-----|------|---------|
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.92"
3
+ version = "3.13.94"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -794,11 +794,18 @@ db.insert("users", {"name": "Alice", "email": "alice@example.com"})
794
794
  # Insert multiple
795
795
  db.insert("users", [{"name": "Bob"}, {"name": "Eve"}])
796
796
 
797
- # Update (by primary key — default "id")
797
+ # Update (the primary key in the data IS the filter introspected, not assumed)
798
798
  db.update("users", {"id": 1, "name": "Alice Updated"})
799
799
 
800
- # Delete
800
+ # Update many rows — the filter must be explicit
801
+ db.update("users", {"active": 0}, "last_login < ?", ["2025-01-01"])
802
+
803
+ # Delete (dict filter or string + params, both accepted)
801
804
  db.delete("users", {"id": 1})
805
+ db.delete("users", "age < ?", [18])
806
+
807
+ # Empty a table — explicit, never accidental
808
+ db.truncate("users")
802
809
 
803
810
  # Raw query
804
811
  result = db.fetch("SELECT * FROM users WHERE age > ?", [18])
@@ -43,7 +43,15 @@ def _resolve_version() -> str:
43
43
  pass
44
44
  except Exception:
45
45
  pass
46
- return "3.13.56"
46
+ # Floor literal, reached only when BOTH lookups above miss. That is not
47
+ # theoretical: the Docker build used to prune dist-info from the installed
48
+ # package, so path 2 raised PackageNotFoundError and the published
49
+ # tina4-python:3.13.94 image served this literal on /health -- then reading
50
+ # "3.13.56", 36 releases stale, because nothing kept it current.
51
+ #
52
+ # test_version_constant.py now asserts this literal equals the pyproject
53
+ # version, so the release bump cannot leave it behind again.
54
+ return "3.13.94"
47
55
 
48
56
 
49
57
  __version__ = _resolve_version()
@@ -188,6 +188,7 @@ class Database:
188
188
  self._connect_kwargs = kwargs # Extra kwargs passed through to adapter.connect()
189
189
  self.last_error = None # Last execute() error message
190
190
  self._last_id = None # Last insert ID from execute/insert
191
+ self._pk_cache = {} # table -> primary-key column name (or None)
191
192
 
192
193
  if self.pool_size > 0:
193
194
  # Pooled mode — create a ConnectionPool with lazy adapter creation
@@ -681,19 +682,123 @@ class Database:
681
682
  self._last_id = result.last_id
682
683
  return result
683
684
 
685
+ def primary_key(self, table: str) -> list[str]:
686
+ """The table's primary-key columns, introspected once and cached.
687
+
688
+ Returns a LIST because a primary key may span several columns. A
689
+ composite key is still one primary key; it just has more than one
690
+ column. Returns ``[]`` when the table has no primary key or cannot be
691
+ introspected.
692
+
693
+ Uses the cross-engine ``get_columns()`` contract (v3.13.14, #48), which
694
+ reports ``primary_key`` per column on every adapter.
695
+ """
696
+ if table not in self._pk_cache:
697
+ try:
698
+ columns = self._get_adapter().get_columns(table)
699
+ self._pk_cache[table] = [
700
+ c["name"] for c in columns if c.get("primary_key")
701
+ ]
702
+ except Exception: # noqa: BLE001 - a missing table is not an error here
703
+ self._pk_cache[table] = []
704
+ return self._pk_cache[table]
705
+
706
+ def _as_where(self, filter_sql, params):
707
+ """Normalise a filter to ``(sql, params)``, accepting a dict or a string.
708
+
709
+ The declared type on ``delete`` has always been ``str | dict | list``, but
710
+ only the base adapter honoured the dict and every engine overrides it, so a
711
+ dict landed in the SQL string verbatim. Normalising here makes the declared
712
+ type true for every engine at once.
713
+ """
714
+ if isinstance(filter_sql, dict):
715
+ if not filter_sql:
716
+ return "", []
717
+ where = " AND ".join(
718
+ f"{self.quote_identifier(k)} = ?" for k in filter_sql
719
+ )
720
+ return where, list(filter_sql.values())
721
+ return filter_sql, params or []
722
+
684
723
  def update(self, table: str, data: dict,
685
- filter_sql: str = "", params: list = None) -> DatabaseResult:
724
+ filter_sql: str | dict = "", params: list = None) -> DatabaseResult:
725
+ """Update rows. A write with no filter is an error, not a full-table write.
726
+
727
+ With no explicit filter, the primary key is taken out of ``data`` and used
728
+ as the WHERE clause. With neither a filter nor a primary key in ``data``,
729
+ this raises rather than overwriting every row (audit feature 4, P1).
730
+ """
731
+ filter_sql, params = self._as_where(filter_sql, params)
732
+
733
+ if not filter_sql:
734
+ pk_columns = self.primary_key(table)
735
+ missing = [c for c in pk_columns if c not in data]
736
+ if not pk_columns or missing:
737
+ raise ValueError(
738
+ f"update requires a filter or the complete primary key in the "
739
+ f"data; pass filter explicitly to update multiple rows "
740
+ f"(table={table!r}, primary key={pk_columns!r}, "
741
+ f"missing from data={missing!r}). "
742
+ f"To empty a table use truncate({table!r})."
743
+ )
744
+ # Every primary-key column becomes part of the WHERE clause. A
745
+ # composite key that used only its first column would match every
746
+ # row sharing that value - the data-loss bug this method exists to
747
+ # prevent, reintroduced.
748
+ data = dict(data)
749
+ params = [data.pop(c) for c in pk_columns]
750
+ if not data:
751
+ raise ValueError(
752
+ f"update was given only the primary key {pk_columns!r} and no "
753
+ f"columns to set (table={table!r})"
754
+ )
755
+ filter_sql = " AND ".join(
756
+ f"{self.quote_identifier(c)} = ?" for c in pk_columns
757
+ )
758
+
686
759
  if self._cache_enabled:
687
760
  self._cache_invalidate()
688
- adapter = self._get_adapter()
689
- return adapter.update(table, data, filter_sql, params)
761
+ result = self._get_adapter().update(table, data, filter_sql, params)
762
+ return self._without_last_id(result)
690
763
 
691
764
  def delete(self, table: str,
692
765
  filter_sql: str | dict | list = "", params: list = None) -> DatabaseResult:
766
+ """Delete rows. A filterless delete raises; use ``truncate()`` to empty."""
767
+ if isinstance(filter_sql, list):
768
+ total = 0
769
+ for row_filter in filter_sql:
770
+ total += self.delete(table, row_filter).affected_rows
771
+ return DatabaseResult(affected_rows=total)
772
+
773
+ filter_sql, params = self._as_where(filter_sql, params)
774
+ if not filter_sql:
775
+ raise ValueError(
776
+ f"delete requires a filter (table={table!r}). "
777
+ f"To remove every row use truncate({table!r})."
778
+ )
779
+
693
780
  if self._cache_enabled:
694
781
  self._cache_invalidate()
695
- adapter = self._get_adapter()
696
- return adapter.delete(table, filter_sql, params)
782
+ result = self._get_adapter().delete(table, filter_sql, params)
783
+ return self._without_last_id(result)
784
+
785
+ def truncate(self, table: str) -> DatabaseResult:
786
+ """Remove every row. The explicit spelling of a whole-table delete."""
787
+ if self._cache_enabled:
788
+ self._cache_invalidate()
789
+ result = self._get_adapter().delete(table, "1 = 1", [])
790
+ return self._without_last_id(result)
791
+
792
+ @staticmethod
793
+ def _without_last_id(result: DatabaseResult) -> DatabaseResult:
794
+ """``last_id`` is insert-only, per the documented contract.
795
+
796
+ PHP already does this via ``writeResult($adapter, withLastId: false)``;
797
+ Python reported the connection's last insert id on an UPDATE.
798
+ """
799
+ if result is not None and getattr(result, "last_id", None) is not None:
800
+ result.last_id = None
801
+ return result
697
802
 
698
803
  def start_transaction(self):
699
804
  """Begin a transaction. Pins the adapter to this thread for the
@@ -1626,11 +1626,57 @@ class Frond:
1626
1626
  )
1627
1627
  """
1628
1628
  self._sandbox = True
1629
- self._allowed_filters = set(allowed_filters) if allowed_filters else None
1630
- self._allowed_tags = set(allowed_tags) if allowed_tags else None
1631
- self._allowed_vars = set(allowed_vars) if allowed_vars else None
1629
+ # `is not None`, NOT truthiness: an EMPTY list means "permit nothing",
1630
+ # while None means "permit everything". Testing truthiness collapsed the
1631
+ # two, so a caller that computed an allow-list and got an empty result
1632
+ # silently opened the sandbox instead of closing it.
1633
+ self._allowed_filters = set(allowed_filters) if allowed_filters is not None else None
1634
+ self._allowed_tags = set(allowed_tags) if allowed_tags is not None else None
1635
+ self._allowed_vars = set(allowed_vars) if allowed_vars is not None else None
1632
1636
  return self
1633
1637
 
1638
+ def _filter_permitted(self, fname: str) -> bool:
1639
+ """Is this filter permitted to run under the current sandbox?
1640
+
1641
+ The single source of truth for "may this filter run", so the escaping
1642
+ decision and the filter loop can never disagree.
1643
+ """
1644
+ if not self._sandbox or self._allowed_filters is None:
1645
+ return True
1646
+ return fname in self._allowed_filters
1647
+
1648
+ #: Node kinds that are TAGS a template author writes, mapped to the name
1649
+ #: they type. Kinds absent from this map are structure (text, output,
1650
+ #: comment, extends/block markers) and are never gated - blocking those
1651
+ #: would stop the template rendering at all rather than restrict it.
1652
+ _GATEABLE_TAGS = {
1653
+ "if": "if",
1654
+ "for": "for",
1655
+ "set": "set",
1656
+ "set_block": "set",
1657
+ "include": "include",
1658
+ "macro": "macro",
1659
+ "from_import": "from",
1660
+ "import_as": "import",
1661
+ "cache": "cache",
1662
+ "spaceless": "spaceless",
1663
+ "autoescape": "autoescape",
1664
+ "live": "live",
1665
+ }
1666
+
1667
+ def _tag_permitted(self, kind: str) -> bool:
1668
+ """Is this tag permitted under the current sandbox?
1669
+
1670
+ One gate for every tag, so the allow-list governs the whole tag
1671
+ vocabulary instead of whichever names somebody remembered to check.
1672
+ """
1673
+ if not self._sandbox or self._allowed_tags is None:
1674
+ return True
1675
+ tag = self._GATEABLE_TAGS.get(kind)
1676
+ if tag is None:
1677
+ return True # structural node, not an author-written tag
1678
+ return tag in self._allowed_tags
1679
+
1634
1680
  def unsandbox(self):
1635
1681
  """Disable sandbox mode."""
1636
1682
  self._sandbox = False
@@ -2017,6 +2063,14 @@ class Frond:
2017
2063
  if node.strip_before and output:
2018
2064
  output[-1] = output[-1].rstrip()
2019
2065
 
2066
+ # Sandbox: ONE tag gate, here at dispatch, so every tag is gated by
2067
+ # construction and a tag added later is gated the day it is added.
2068
+ # This replaced a single per-name conditional that covered `include`
2069
+ # only, which let `{% autoescape false %}` turn escaping OFF from
2070
+ # inside a sandbox that had not allowed it (audit feature 38, P1b).
2071
+ if not self._tag_permitted(kind):
2072
+ continue
2073
+
2020
2074
  if kind == "output":
2021
2075
  result = self._eval_var(node.expr, context)
2022
2076
  output.append(self._to_output(result))
@@ -2042,10 +2096,9 @@ class Frond:
2042
2096
  )
2043
2097
 
2044
2098
  elif kind == "include":
2045
- # Sandbox: check tag
2046
- if not (self._sandbox and self._allowed_tags is not None
2047
- and "include" not in self._allowed_tags):
2048
- output.append(self._handle_include(node.content, context))
2099
+ # The sandbox tag gate now runs once at dispatch (above), so the
2100
+ # per-name conditional that used to live here is gone.
2101
+ output.append(self._handle_include(node.content, context))
2049
2102
 
2050
2103
  elif kind == "macro":
2051
2104
  self._handle_macro(node, context)
@@ -2275,7 +2328,14 @@ class Frond:
2275
2328
  # the var_name eval lets a parenthesised pipe like `{{ (a|f) }}` resolve
2276
2329
  # too — the old code returned empty for that.
2277
2330
  value = _eval_expr(var_name, context, self._apply_filters)
2278
- is_safe = any(name in ("raw", "safe") for name, _ in filters)
2331
+ # `raw`/`safe` suppress escaping only if the sandbox PERMITS them to run.
2332
+ # Reading the filter name straight out of the parsed source meant a
2333
+ # sandbox that DENIED raw still marked the value safe, so denying raw
2334
+ # produced byte-identical output to allowing it (audit feature 38, P1).
2335
+ is_safe = any(
2336
+ name in ("raw", "safe") and self._filter_permitted(name)
2337
+ for name, _ in filters
2338
+ )
2279
2339
  value = self._apply_filters(value, filters, context)
2280
2340
 
2281
2341
  # Auto-escape HTML unless marked safe or SafeString
@@ -106,6 +106,11 @@ class Messenger:
106
106
  encryption: str = None, use_tls: bool = None,
107
107
  imap_host: str = None, imap_port: int = None):
108
108
  # SMTP (send) — priority: constructor > .env > sensible default
109
+ # Whether a host was actually CONFIGURED, which is not the same as
110
+ # self.host being set: self.host falls back to "localhost", so it is never
111
+ # empty and cannot answer "can this messenger send?". The dev-capture gate
112
+ # needs that answer, so record it here while the real inputs are in scope.
113
+ self._smtp_configured = bool(host or os.environ.get("TINA4_MAIL_HOST"))
109
114
  self.host = host or os.environ.get("TINA4_MAIL_HOST", "localhost")
110
115
  self.port = port or int(os.environ.get("TINA4_MAIL_PORT", "587"))
111
116
  self.username = username or os.environ.get("TINA4_MAIL_USERNAME", "")
@@ -141,6 +146,37 @@ class Messenger:
141
146
  """Add a default header to all outgoing emails."""
142
147
  self._default_headers[name] = value
143
148
 
149
+ def _should_capture(self) -> bool:
150
+ """Should send() capture to a local mailbox instead of talking to SMTP?
151
+
152
+ Availability decides, not verbosity. With no SMTP host configured sending
153
+ is impossible, so simulate it into a folder rather than failing -- that is
154
+ what makes a laptop with no mail server usable. ``TINA4_MAIL_CAPTURE``
155
+ forces capture even when a host IS configured, for anyone who wants
156
+ "never send real mail from this box".
157
+
158
+ ``TINA4_DEBUG`` deliberately does NOT gate this. Debug must still be able
159
+ to send: tying capture to it means nobody can test a real send from a dev
160
+ box, which is the common case.
161
+ """
162
+ from tina4_python.dotenv import is_truthy
163
+ if is_truthy(os.environ.get("TINA4_MAIL_CAPTURE", "")):
164
+ return True
165
+ return not self._smtp_configured
166
+
167
+ def _dev_mailbox(self) -> "DevMailbox":
168
+ """The local mailbox, created on first capture and reused after.
169
+
170
+ Attached lazily so a messenger that never captures never grows the
171
+ attribute -- callers test ``hasattr(messenger, "dev_mailbox")`` to ask
172
+ "is this a capturing messenger?".
173
+ """
174
+ mailbox = getattr(self, "dev_mailbox", None)
175
+ if mailbox is None:
176
+ mailbox = DevMailbox()
177
+ self.dev_mailbox = mailbox
178
+ return mailbox
179
+
144
180
  def send(self, to: str | list[str], subject: str, body: str,
145
181
  html: bool = False, text: str = None,
146
182
  cc: str | list[str] = None,
@@ -167,6 +203,21 @@ class Messenger:
167
203
  cc_list = [cc] if isinstance(cc, str) else list(cc or [])
168
204
  bcc_list = [bcc] if isinstance(bcc, str) else list(bcc or [])
169
205
 
206
+ # Dev capture is a BRANCH here, not a method swapped onto the instance.
207
+ # The swap was the cause of tina4-nodejs#42 in Python: dev_send declared
208
+ # (to, subject, body, html, cc, ...) while this method declares
209
+ # (to, subject, body, html, text, cc, ...), so one name meant two
210
+ # signatures -- send(to, subj, body, True, "plain text") filed the
211
+ # plain-text body as a CC RECIPIENT and reported success, and
212
+ # send(text=...) raised TypeError on a dev messenger.
213
+ if self._should_capture():
214
+ return self._dev_mailbox().capture(
215
+ to, subject, body, html, text,
216
+ cc=cc_list, bcc=bcc_list, reply_to=reply_to,
217
+ from_address=self.from_address, from_name=self.from_name,
218
+ attachments=attachments,
219
+ )
220
+
170
221
  has_attachments = bool(attachments)
171
222
  has_text_alt = text is not None and html
172
223
 
@@ -728,24 +779,44 @@ class DevMailbox:
728
779
  self._inbox_dir.mkdir(exist_ok=True)
729
780
 
730
781
  def capture(self, to: str | list[str], subject: str, body: str,
731
- html: bool = False, cc: list[str] = None,
732
- bcc: list[str] = None, reply_to: str = None,
782
+ html: bool = False, text: str = None,
783
+ cc: str | list[str] = None,
784
+ bcc: str | list[str] = None, reply_to: str = None,
733
785
  from_address: str = "", from_name: str = "",
734
786
  attachments: list = None) -> dict:
735
- """Capture a message to the local outbox (instead of sending via SMTP)."""
787
+ """Capture a message to the local outbox (instead of sending via SMTP).
788
+
789
+ The parameter order MATCHES ``Messenger.send`` on purpose. It did not
790
+ before: send's 5th positional was ``text`` and capture's was ``cc``, so the
791
+ same call meant different things depending on which door it came through --
792
+ that mismatch IS tina4-nodejs#42.
793
+
794
+ BREAKING: ``text`` is now the 5th positional. A caller passing ``cc``
795
+ positionally must switch to the keyword form. Aligning the two signatures
796
+ is the fix; leaving them apart would preserve the bug.
797
+
798
+ cc/bcc are normalised HERE, at the boundary, so a message is well formed
799
+ however it arrived. Normalising in one caller only (which is what Python
800
+ did -- in dev_send, never in capture) means a direct caller stores a
801
+ malformed message and is told it succeeded, and a dev mailbox that accepts
802
+ a broken message defeats its own purpose.
803
+ """
736
804
  msg_id = f"{int(time.time() * 1000)}_{id(subject) & 0xFFFF:04x}"
737
805
  to_list = [to] if isinstance(to, str) else list(to)
806
+ cc_list = [cc] if isinstance(cc, str) else list(cc or [])
807
+ bcc_list = [bcc] if isinstance(bcc, str) else list(bcc or [])
738
808
 
739
809
  message = {
740
810
  "id": msg_id,
741
811
  "type": "outbox",
742
812
  "from": f"{from_name} <{from_address}>" if from_name else from_address,
743
813
  "to": to_list,
744
- "cc": cc or [],
745
- "bcc": bcc or [],
814
+ "cc": cc_list,
815
+ "bcc": bcc_list,
746
816
  "reply_to": reply_to or "",
747
817
  "subject": subject,
748
818
  "body": body,
819
+ "text": text,
749
820
  "html": html,
750
821
  "attachments": [
751
822
  a if isinstance(a, str) else a.get("filename", "attachment")
@@ -909,31 +980,22 @@ def _is_dev_mode() -> bool:
909
980
  def create_messenger(**kwargs) -> Messenger:
910
981
  """Factory that returns a Messenger configured for the current environment.
911
982
 
912
- In dev mode (TINA4_DEBUG=true), email sending is intercepted
913
- by DevMailbox no SMTP connection needed. The original Messenger.send()
914
- is replaced with a local capture.
983
+ Returns ONE concrete type, always. When sending is impossible (no SMTP host
984
+ configured) or suppressed (``TINA4_MAIL_CAPTURE``), ``send()`` captures to a
985
+ local ``DevMailbox`` instead -- decided by a branch inside ``send()``, so the
986
+ object you get back has one ``send`` with one signature either way.
987
+
988
+ It no longer replaces ``send`` on the instance. That swap installed a function
989
+ with a DIFFERENT signature than ``Messenger.send`` under the same name, which
990
+ is how the documented call ``send(to, subj, body, True, "plain text")`` came to
991
+ file the plain-text body as a CC recipient and report success.
915
992
  """
916
993
  messenger = Messenger(**kwargs)
917
994
 
918
- if _is_dev_mode():
919
- mailbox = DevMailbox()
920
- # Monkey-patch send to capture locally
921
- _original_send = messenger.send
922
-
923
- def dev_send(to, subject, body, html=False, cc=None, bcc=None,
924
- reply_to=None, attachments=None, headers=None):
925
- return mailbox.capture(
926
- to=to, subject=subject, body=body, html=html,
927
- cc=[cc] if isinstance(cc, str) else (cc or []),
928
- bcc=[bcc] if isinstance(bcc, str) else (bcc or []),
929
- reply_to=reply_to,
930
- from_address=messenger.from_address,
931
- from_name=messenger.from_name,
932
- attachments=attachments,
933
- )
934
-
935
- messenger.send = dev_send
936
- messenger.dev_mailbox = mailbox
995
+ # Attach the mailbox eagerly when this messenger will capture, so callers can
996
+ # inspect it (and the dev admin panel can list it) before the first send.
997
+ if messenger._should_capture():
998
+ messenger.dev_mailbox = DevMailbox()
937
999
 
938
1000
  return messenger
939
1001