tina4-python 3.13.136__tar.gz → 3.13.137__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 (176) hide show
  1. {tina4_python-3.13.136 → tina4_python-3.13.137}/PKG-INFO +1 -1
  2. {tina4_python-3.13.136 → tina4_python-3.13.137}/pyproject.toml +7 -4
  3. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/ai/client.py +14 -6
  5. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/server.py +5 -1
  6. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/migration/runner.py +23 -9
  7. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/push/__init__.py +59 -35
  8. tina4_python-3.13.137/tina4_python/push/_base.py +38 -0
  9. tina4_python-3.13.137/tina4_python/push/_cryptography.py +66 -0
  10. tina4_python-3.13.137/tina4_python/push/_libcrypto.py +261 -0
  11. {tina4_python-3.13.136 → tina4_python-3.13.137}/.gitignore +0 -0
  12. {tina4_python-3.13.136 → tina4_python-3.13.137}/README.md +0 -0
  13. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/.tina4-metrics.json +0 -0
  14. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/CLAUDE.md +0 -0
  15. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/HtmlElement.py +0 -0
  16. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/Testing.py +0 -0
  17. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/_import_helper.py +0 -0
  18. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/ai/__init__.py +0 -0
  19. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/api/__init__.py +0 -0
  20. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/auth/__init__.py +0 -0
  21. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/cache/__init__.py +0 -0
  22. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/cli/__init__.py +0 -0
  23. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/container/__init__.py +0 -0
  24. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/context/__init__.py +0 -0
  25. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/context/chunker.py +0 -0
  26. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/__init__.py +0 -0
  27. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/cache.py +0 -0
  28. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/constants.py +0 -0
  29. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/events.py +0 -0
  30. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/middleware.py +0 -0
  31. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/port_takeover.py +0 -0
  32. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/rate_limiter.py +0 -0
  33. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/request.py +0 -0
  34. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/response.py +0 -0
  35. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/router.py +0 -0
  36. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/crud/__init__.py +0 -0
  37. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/__init__.py +0 -0
  38. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/adapter.py +0 -0
  39. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/connection.py +0 -0
  40. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/database_url.py +0 -0
  41. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/firebird.py +0 -0
  42. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/mongodb.py +0 -0
  43. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/mssql.py +0 -0
  44. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/mysql.py +0 -0
  45. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/odbc.py +0 -0
  46. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/postgres.py +0 -0
  47. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/sql_translator.py +0 -0
  48. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/sqlite.py +0 -0
  49. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/debug/__init__.py +0 -0
  50. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/debug/error_overlay.py +0 -0
  51. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dev_admin/__init__.py +0 -0
  52. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dev_admin/metrics.py +0 -0
  53. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dev_admin/plan.py +0 -0
  54. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dev_admin/project_index.py +0 -0
  55. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/docs.py +0 -0
  56. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/docstore/__init__.py +0 -0
  57. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dotenv/__init__.py +0 -0
  58. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/env.py +0 -0
  59. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/FROND.md +0 -0
  60. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/__init__.py +0 -0
  61. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/compiler.py +0 -0
  62. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/engine.py +0 -0
  63. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/parser.py +0 -0
  64. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/auth/meta.json +0 -0
  65. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  66. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/database/meta.json +0 -0
  67. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  68. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/error-overlay/meta.json +0 -0
  69. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  70. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/orm/meta.json +0 -0
  71. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  72. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  73. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/queue/meta.json +0 -0
  74. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  75. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/rest-api/meta.json +0 -0
  76. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  77. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/templates/meta.json +0 -0
  78. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  79. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  80. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/__init__.py +0 -0
  81. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapter.py +0 -0
  82. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapters/__init__.py +0 -0
  83. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapters/arango.py +0 -0
  84. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapters/bolt.py +0 -0
  85. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapters/ultipa.py +0 -0
  86. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/graph_url.py +0 -0
  87. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graphql/__init__.py +0 -0
  88. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/i18n/__init__.py +0 -0
  89. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mcp/__init__.py +0 -0
  90. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mcp/protocol.py +0 -0
  91. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mcp/tools.py +0 -0
  92. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/messenger/__init__.py +0 -0
  93. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/migration/__init__.py +0 -0
  94. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mqtt/__init__.py +0 -0
  95. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mqtt/message.py +0 -0
  96. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/__init__.py +0 -0
  97. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/collection.py +0 -0
  98. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/fields.py +0 -0
  99. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/model.py +0 -0
  100. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/point.py +0 -0
  101. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/__feedback/widget.js +0 -0
  102. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/css/tina4.css +0 -0
  103. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/css/tina4.min.css +0 -0
  104. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/favicon.ico +0 -0
  105. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/images/logo.svg +0 -0
  106. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  107. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/frond.js +0 -0
  108. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/frond.min.js +0 -0
  109. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  110. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/tina4.min.js +0 -0
  111. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/tina4js.min.js +0 -0
  112. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/swagger/index.html +0 -0
  113. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  114. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/query_builder/__init__.py +0 -0
  115. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/__init__.py +0 -0
  116. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/amqp_url.py +0 -0
  117. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/job.py +0 -0
  118. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/kafka_backend.py +0 -0
  119. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/lite_backend.py +0 -0
  120. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/mongo_backend.py +0 -0
  121. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/rabbitmq_backend.py +0 -0
  122. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue_backends/__init__.py +0 -0
  123. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue_backends/kafka_backend.py +0 -0
  124. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue_backends/mongo_backend.py +0 -0
  125. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  126. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/__init__.py +0 -0
  127. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/Attachment.py +0 -0
  128. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/Channel.py +0 -0
  129. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/ChannelMember.py +0 -0
  130. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/Message.py +0 -0
  131. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/Workspace.py +0 -0
  132. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/__init__.py +0 -0
  133. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/storage.py +0 -0
  134. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/seeder/__init__.py +0 -0
  135. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/service/__init__.py +0 -0
  136. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session/__init__.py +0 -0
  137. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/__init__.py +0 -0
  138. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/memcached_handler.py +0 -0
  139. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  140. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/redis_handler.py +0 -0
  141. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/valkey_handler.py +0 -0
  142. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/sso.py +0 -0
  143. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/swagger/__init__.py +0 -0
  144. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/components/crud.twig +0 -0
  145. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  146. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  147. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/docker/python/Dockerfile +0 -0
  148. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  149. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/302.twig +0 -0
  150. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/401.twig +0 -0
  151. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/403.twig +0 -0
  152. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/404.twig +0 -0
  153. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/500.twig +0 -0
  154. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/502.twig +0 -0
  155. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/503.twig +0 -0
  156. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/base.twig +0 -0
  157. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/frontend/README.md +0 -0
  158. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/readme.md +0 -0
  159. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/test/__init__.py +0 -0
  160. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/test_client/__init__.py +0 -0
  161. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  166. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  167. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  168. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  169. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  170. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  171. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  172. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  173. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/validator/__init__.py +0 -0
  174. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/websocket/__init__.py +0 -0
  175. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/websocket/backplane.py +0 -0
  176. {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.136
3
+ Version: 3.13.137
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.136"
3
+ version = "3.13.137"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -41,9 +41,12 @@ s3 = ["boto3>=1.34"]
41
41
  kafka = ["confluent-kafka>=2.0"]
42
42
  rabbitmq = ["pika>=1.3"]
43
43
 
44
- # Web Push is zero-dependency until selected. The standard library has no
45
- # P-256 ECDH or ES256 primitives, so the native cryptography capability is
46
- # installed explicitly when a project enables Web Push.
44
+ # Web Push is zero-dependency on the server: the crypto runs through the OS
45
+ # OpenSSL (libcrypto) by ctypes, so a Linux host needs nothing installed. This
46
+ # extra is the DEV FALLBACK - on a stock Mac or a Windows workstation ctypes
47
+ # cannot reach a safe OpenSSL, so the cryptography package runs instead. Install
48
+ # it there (or pin it with TINA4_PUSH_BACKEND=cryptography); the wire output is
49
+ # identical either way. See tina4_python/push/_libcrypto.py.
47
50
  push = ["cryptography>=42.0"]
48
51
 
49
52
  # All database drivers
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.136"
54
+ return "3.13.137"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -98,7 +98,7 @@ class Ai:
98
98
  Explicit call options beat environment values, which beat defaults.
99
99
  """
100
100
 
101
- _PROVIDERS = {"local", "openai", "anthropic"}
101
+ _PROVIDERS = {"local", "openai", "anthropic", "gemini"}
102
102
 
103
103
  @staticmethod
104
104
  def chat(
@@ -369,15 +369,19 @@ class Ai:
369
369
  def _config(capability: str, *, model: str | None, timeout: float | None, provider: str | None) -> _Config:
370
370
  selected = (provider or os.environ.get("TINA4_AI_PROVIDER") or "local").strip().lower()
371
371
  if selected not in Ai._PROVIDERS:
372
- raise AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic")
372
+ raise AiConfigError("TINA4_AI_PROVIDER must be local, openai, anthropic, or gemini")
373
373
  key = os.environ.get("TINA4_AI_KEY") or None
374
- if selected in {"openai", "anthropic"} and not key:
374
+ if selected in {"openai", "anthropic", "gemini"} and not key:
375
375
  raise AiConfigError(f"TINA4_AI_KEY is required for the {selected} provider")
376
376
 
377
377
  defaults = {
378
378
  "local": ("http://localhost:11437", "llama3.2"),
379
379
  "openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
380
380
  "anthropic": ("https://api.anthropic.com/v1", "claude-3-5-haiku-latest"),
381
+ # Gemini speaks the OpenAI wire format at its OpenAI-compatible base, so it
382
+ # rides the openai body/parse/stream path - only the base URL, the endpoint
383
+ # suffix append, and the Bearer key differ.
384
+ "gemini": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"),
381
385
  }
382
386
  base, default_model = defaults[selected]
383
387
  if capability == "embed" and os.environ.get("TINA4_EMBED_URL"):
@@ -408,8 +412,12 @@ class Ai:
408
412
  suffix = "/embeddings"
409
413
  else:
410
414
  suffix = "/chat/completions"
411
- if path in {"", "/v1", "/api"}:
412
- prefix = path or ("/v1" if provider in {"local", "openai"} else "/v1")
415
+ # "/v1beta/openai" is Gemini's OpenAI-compatible base; append the suffix onto
416
+ # it exactly as onto a bare host or "/v1", so the default gemini base resolves
417
+ # to .../v1beta/openai/chat/completions (or /embeddings). A full endpoint URL
418
+ # the caller supplies verbatim still passes through untouched.
419
+ if path in {"", "/v1", "/api", "/v1beta/openai"}:
420
+ prefix = path or "/v1"
413
421
  path = prefix + suffix
414
422
  parsed = parsed._replace(path=path)
415
423
  return parsed.geturl()
@@ -418,7 +426,7 @@ class Ai:
418
426
  @staticmethod
419
427
  def _headers(config: _Config) -> dict[str, str]:
420
428
  headers = {"content-type": "application/json", "accept": "application/json"}
421
- if config.provider == "openai" and config.key:
429
+ if config.provider in {"openai", "gemini"} and config.key:
422
430
  headers["authorization"] = f"Bearer {config.key}"
423
431
  elif config.provider == "anthropic" and config.key:
424
432
  headers["x-api-key"] = config.key
@@ -2588,7 +2588,11 @@ def _stage_dev_toolbar_inject(ctx: DispatchContext) -> None:
2588
2588
  Best-effort: a toolbar that fails to render must never break the response
2589
2589
  it was decorating, so the whole thing is guarded.
2590
2590
  """
2591
- if not ctx.is_dev or not ctx.response.content_type:
2591
+ if (
2592
+ not ctx.is_dev
2593
+ or not ctx.response.content_type
2594
+ or ctx.response.status_code == 204
2595
+ ):
2592
2596
  return None
2593
2597
  if "text/html" not in ctx.response.content_type:
2594
2598
  return None
@@ -156,7 +156,13 @@ def _build_stem_map(migration_folder: str | None) -> list[str]:
156
156
  def _resolve_migration_name(description: str, stems: list[str]) -> str:
157
157
  """Find the file stem that corresponds to a v2 description, or fall back.
158
158
 
159
- Match strategy, in order:
159
+ Python v2 recorded `description` straight from os.listdir, so the value
160
+ normally carries the file extension (".sql"/".py") while a stem never does.
161
+ Match on the extension-stripped form as well, or a whole v2 history looks
162
+ unapplied and replays — see tests/test_issue_115_v2_upgrade.py.
163
+
164
+ Match strategy, in order (each tried against the description and against
165
+ its extension-stripped form):
160
166
  1. Exact stem match.
161
167
  2. A stem ending in "_" + description (e.g. "000001_create_users"
162
168
  resolves a v2 description of "create_users").
@@ -165,14 +171,22 @@ def _resolve_migration_name(description: str, stems: list[str]) -> str:
165
171
  getAppliedMigrations() but migrate() may re-run if a real file
166
172
  exists with a different name.
167
173
  """
168
- if description in stems:
169
- return description
170
- for stem in stems:
171
- if stem.endswith("_" + description):
172
- return stem
173
- for stem in stems:
174
- if description in stem:
175
- return stem
174
+ candidates = [description]
175
+ bare = re.sub(r"\.(sql|py)$", "", description, flags=re.IGNORECASE)
176
+ if bare != description:
177
+ candidates.append(bare)
178
+
179
+ for candidate in candidates:
180
+ if candidate in stems:
181
+ return candidate
182
+ for candidate in candidates:
183
+ for stem in stems:
184
+ if stem.endswith("_" + candidate):
185
+ return stem
186
+ for candidate in candidates:
187
+ for stem in stems:
188
+ if candidate in stem:
189
+ return stem
176
190
  return description
177
191
 
178
192
 
@@ -1,8 +1,13 @@
1
1
  """Provider-neutral Web Push delivery (RFC 8291 / RFC 8292).
2
2
 
3
- The framework remains zero-dependency. Install the optional capability with
4
- ``pip install tina4-python[push]``; ``cryptography`` is imported only when a
5
- push sender is constructed or used.
3
+ The framework stays zero-dependency on the server. The crypto runs through the OS
4
+ OpenSSL (``libcrypto``) by ctypes - the same "use the platform crypto" model the
5
+ PHP, Ruby and Node frameworks get from their stdlib - so a Linux host sends push
6
+ with nothing installed. Where ctypes cannot reach a safe OpenSSL (a stock Mac, a
7
+ Windows workstation), it falls back to the optional ``cryptography`` package
8
+ (``pip install tina4-python[push]``). Both backends emit identical bytes, so the
9
+ wire output is the same whichever runs. Set ``TINA4_PUSH_BACKEND`` to ``libcrypto``
10
+ or ``cryptography`` to pin one; ``auto`` (default) prefers libcrypto.
6
11
  """
7
12
  from __future__ import annotations
8
13
 
@@ -19,6 +24,8 @@ import urllib.request
19
24
  from dataclasses import dataclass
20
25
  from typing import Any
21
26
 
27
+ from ._base import BackendUnavailable
28
+
22
29
  RECORD_SIZE = 4096
23
30
  MAX_PAYLOAD = RECORD_SIZE - 17
24
31
 
@@ -50,15 +57,45 @@ def _unb64(value: str, name: str) -> bytes:
50
57
  return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
51
58
 
52
59
 
53
- def _crypto() -> tuple[Any, ...]:
54
- try:
55
- from cryptography.hazmat.primitives import hashes, serialization
56
- from cryptography.hazmat.primitives.asymmetric import ec
57
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
58
- from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
59
- except ImportError as exc:
60
- raise PushError("Web Push requires the optional crypto capability; install tina4-python[push]") from exc
61
- return hashes, serialization, ec, Cipher, algorithms, modes, decode_dss_signature
60
+ _BACKEND = None
61
+
62
+
63
+ def _select_backend():
64
+ """Pick the crypto backend: OS libcrypto first, cryptography as fallback.
65
+
66
+ ``TINA4_PUSH_BACKEND`` forces ``auto`` (default), ``libcrypto`` or
67
+ ``cryptography``. The choice is cached for the process; ``_reset_backend``
68
+ drops the cache so a later call re-selects after the environment changes.
69
+ """
70
+ global _BACKEND
71
+ if _BACKEND is not None:
72
+ return _BACKEND
73
+ choice = os.getenv("TINA4_PUSH_BACKEND", "auto").strip().lower() or "auto"
74
+ order = {"auto": ("libcrypto", "cryptography"),
75
+ "libcrypto": ("libcrypto",),
76
+ "cryptography": ("cryptography",)}.get(choice)
77
+ if order is None:
78
+ raise PushError("TINA4_PUSH_BACKEND must be auto, libcrypto or cryptography")
79
+ reasons = []
80
+ for name in order:
81
+ if name == "libcrypto":
82
+ from . import _libcrypto as backend_module
83
+ else:
84
+ from . import _cryptography as backend_module
85
+ try:
86
+ _BACKEND = backend_module.load()
87
+ return _BACKEND
88
+ except BackendUnavailable as exc:
89
+ reasons.append(f"{name} ({exc})")
90
+ raise PushError(
91
+ "Web Push needs the OS OpenSSL (Linux/Unix) or the cryptography package "
92
+ "(install tina4-python[push]); unavailable here: " + ", ".join(reasons))
93
+
94
+
95
+ def _reset_backend() -> None:
96
+ """Drop the cached backend so the next call re-selects."""
97
+ global _BACKEND
98
+ _BACKEND = None
62
99
 
63
100
 
64
101
  def _hmac(key: bytes, value: bytes) -> bytes:
@@ -78,15 +115,8 @@ def _hkdf(prk: bytes, info: bytes, length: int) -> bytes:
78
115
  return b"".join(chunks)[:length]
79
116
 
80
117
 
81
- def _public_bytes(public_key: Any, serialization: Any) -> bytes:
82
- return public_key.public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
83
-
84
-
85
118
  def generate_vapid_keys() -> dict[str, str]:
86
- _hashes, serialization, ec, *_ = _crypto()
87
- private = ec.generate_private_key(ec.SECP256R1())
88
- public = _public_bytes(private.public_key(), serialization)
89
- raw_private = private.private_numbers().private_value.to_bytes(32, "big")
119
+ raw_private, public = _select_backend().generate_keypair()
90
120
  return {"publicKey": _b64(public), "privateKey": _b64(raw_private)}
91
121
 
92
122
 
@@ -104,7 +134,7 @@ def _payload_bytes(payload: Any) -> bytes:
104
134
  def _encrypt(payload: bytes, subscription: dict[str, Any]) -> bytes:
105
135
  if len(payload) > MAX_PAYLOAD:
106
136
  raise PushError(f"Push payload is too large; maximum is {MAX_PAYLOAD} bytes")
107
- _hashes, serialization, ec, Cipher, algorithms, modes, _decode = _crypto()
137
+ backend = _select_backend()
108
138
  try:
109
139
  client_public_bytes = _unb64(subscription["keys"]["p256dh"], "subscription.keys.p256dh")
110
140
  auth_secret = _unb64(subscription["keys"]["auth"], "subscription.keys.auth")
@@ -115,32 +145,27 @@ def _encrypt(payload: bytes, subscription: dict[str, Any]) -> bytes:
115
145
  if len(auth_secret) != 16:
116
146
  raise PushError("subscription.keys.auth must be a 16-byte authentication secret")
117
147
 
118
- client_public = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), client_public_bytes)
119
- ephemeral = ec.generate_private_key(ec.SECP256R1())
120
- server_public_bytes = _public_bytes(ephemeral.public_key(), serialization)
121
- shared = ephemeral.exchange(ec.ECDH(), client_public)
148
+ try:
149
+ server_public_bytes, shared = backend.encrypt_ecdh(client_public_bytes)
150
+ except ValueError as exc:
151
+ raise PushError("subscription.keys.p256dh is not a valid P-256 public key") from exc
122
152
  key_info = b"WebPush: info\0" + client_public_bytes + server_public_bytes
123
153
  ikm = _hkdf(_hmac(auth_secret, shared), key_info, 32)
124
154
  salt = os.urandom(16)
125
155
  prk = _hmac(salt, ikm)
126
156
  cek = _hkdf(prk, b"Content-Encoding: aes128gcm\0", 16)
127
157
  nonce = _hkdf(prk, b"Content-Encoding: nonce\0", 12)
128
- encryptor = Cipher(algorithms.AES(cek), modes.GCM(nonce)).encryptor()
129
- ciphertext = encryptor.update(payload + b"\x02") + encryptor.finalize() + encryptor.tag
158
+ ciphertext = backend.aes128gcm_encrypt(cek, nonce, payload + b"\x02")
130
159
  return salt + RECORD_SIZE.to_bytes(4, "big") + bytes([len(server_public_bytes)]) + server_public_bytes + ciphertext
131
160
 
132
161
 
133
162
  def _vapid_token(endpoint: str, subject: str, raw_private: bytes, raw_public: bytes) -> str:
134
- _hashes, serialization, ec, _Cipher, _algorithms, _modes, decode_dss_signature = _crypto()
135
163
  audience = urllib.parse.urlparse(endpoint)
136
164
  aud = f"{audience.scheme}://{audience.netloc}"
137
165
  header = _b64(json.dumps({"typ": "JWT", "alg": "ES256"}, separators=(",", ":")).encode())
138
166
  claims = _b64(json.dumps({"aud": aud, "exp": int(time.time()) + 12 * 60 * 60, "sub": subject}, separators=(",", ":")).encode())
139
167
  signing_input = f"{header}.{claims}".encode("ascii")
140
- private = ec.derive_private_key(int.from_bytes(raw_private, "big"), ec.SECP256R1())
141
- der = private.sign(signing_input, ec.ECDSA(_hashes.SHA256()))
142
- r, s = decode_dss_signature(der)
143
- signature = r.to_bytes(32, "big") + s.to_bytes(32, "big")
168
+ signature = _select_backend().sign_es256(raw_private, signing_input)
144
169
  return signing_input.decode("ascii") + "." + _b64(signature)
145
170
 
146
171
 
@@ -196,12 +221,11 @@ class Push:
196
221
  raise PushError("TINA4_VAPID_PUBLIC must be a 65-byte P-256 public key")
197
222
  if len(raw_private) != 32:
198
223
  raise PushError("TINA4_VAPID_PRIVATE must be a 32-byte P-256 private key")
199
- _hashes, serialization, ec, *_ = _crypto()
200
224
  try:
201
- derived = ec.derive_private_key(int.from_bytes(raw_private, "big"), ec.SECP256R1())
225
+ derived_public = _select_backend().public_from_private(raw_private)
202
226
  except ValueError as exc:
203
227
  raise PushError("TINA4_VAPID_PRIVATE is not a valid P-256 private key") from exc
204
- if _public_bytes(derived.public_key(), serialization) != raw_public:
228
+ if derived_public != raw_public:
205
229
  raise PushError("TINA4_VAPID_PUBLIC does not match TINA4_VAPID_PRIVATE")
206
230
  return raw_public, raw_private
207
231
 
@@ -0,0 +1,38 @@
1
+ """The Web Push crypto backend contract.
2
+
3
+ A backend supplies the five primitives RFC 8291 / RFC 8292 need that CPython's
4
+ stdlib cannot: P-256 keygen, ECDH, AES-128-GCM, the public point of a stored
5
+ scalar, and an ES256 signature. Everything else (HKDF, HMAC, the record framing,
6
+ the JWT assembly) is pure stdlib and lives in ``__init__``, written once and
7
+ shared by both backends.
8
+
9
+ Two backends implement this contract: ``_libcrypto`` (ctypes -> the OS OpenSSL,
10
+ zero packages, the server path) and ``_cryptography`` (the pyca package, the
11
+ Mac/Windows dev fallback). Both return the SAME bytes, so the wire output stays
12
+ identical to the PHP, Ruby and Node frameworks whichever one runs.
13
+
14
+ Each method raises ``ValueError`` on malformed key material. A backend that
15
+ cannot run on this host raises ``BackendUnavailable`` from its ``load()`` so the
16
+ selector can fall through to the next one.
17
+
18
+ Contract (all keys are raw bytes, points are 65-byte uncompressed ``0x04`` form):
19
+
20
+ generate_keypair() -> (raw_private32, public65)
21
+ A fresh random P-256 keypair.
22
+ public_from_private(raw_private32) -> public65
23
+ The public point for a stored scalar (used to validate a VAPID pair).
24
+ encrypt_ecdh(peer_public65) -> (server_public65, shared32)
25
+ Generate an ephemeral keypair, ECDH against the subscriber's key, and
26
+ hand back both the ephemeral public point and the shared secret. The
27
+ ephemeral private key never leaves the backend.
28
+ aes128gcm_encrypt(key16, iv12, plaintext) -> ciphertext_with_tag
29
+ AES-128-GCM; the 16-byte tag is appended to the ciphertext.
30
+ sign_es256(raw_private32, message) -> signature64
31
+ ECDSA P-256 / SHA-256 over ``message``, as raw r||s (each left-padded
32
+ to 32 bytes).
33
+ """
34
+ from __future__ import annotations
35
+
36
+
37
+ class BackendUnavailable(RuntimeError):
38
+ """Raised by a backend's ``load()`` when it cannot run on this host."""
@@ -0,0 +1,66 @@
1
+ """Web Push crypto through the pyca ``cryptography`` package.
2
+
3
+ The dev fallback. When ctypes cannot reach a safe OS OpenSSL - a stock Mac, a
4
+ Windows workstation, a musl image without OpenSSL - this backend runs instead,
5
+ provided the optional package is installed (``pip install tina4-python[push]``).
6
+ It produces the same bytes as the libcrypto backend, so the wire output does not
7
+ change with the host.
8
+
9
+ ``load()`` raises ``BackendUnavailable`` when the package is absent, so the
10
+ selector can report both misses in one clear error.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from ._base import BackendUnavailable
15
+
16
+
17
+ class CryptographyBackend:
18
+ """RFC 8291 / RFC 8292 primitives over the pyca cryptography package."""
19
+
20
+ name = "cryptography"
21
+
22
+ def __init__(self, module_bundle):
23
+ (self._ec, self._AESGCM, self._Encoding, self._PublicFormat,
24
+ self._hashes, self._decode_dss) = module_bundle
25
+
26
+ def _public_bytes(self, public_key) -> bytes:
27
+ return public_key.public_bytes(self._Encoding.X962, self._PublicFormat.UncompressedPoint)
28
+
29
+ def generate_keypair(self):
30
+ priv = self._ec.generate_private_key(self._ec.SECP256R1())
31
+ pub = self._public_bytes(priv.public_key())
32
+ raw = priv.private_numbers().private_value.to_bytes(32, "big")
33
+ return raw, pub
34
+
35
+ def public_from_private(self, raw_private: bytes) -> bytes:
36
+ priv = self._ec.derive_private_key(int.from_bytes(raw_private, "big"), self._ec.SECP256R1())
37
+ return self._public_bytes(priv.public_key())
38
+
39
+ def encrypt_ecdh(self, peer_public: bytes):
40
+ peer = self._ec.EllipticCurvePublicKey.from_encoded_point(self._ec.SECP256R1(), peer_public)
41
+ ephemeral = self._ec.generate_private_key(self._ec.SECP256R1())
42
+ server_pub = self._public_bytes(ephemeral.public_key())
43
+ shared = ephemeral.exchange(self._ec.ECDH(), peer)
44
+ return server_pub, shared
45
+
46
+ def aes128gcm_encrypt(self, key: bytes, iv: bytes, plaintext: bytes) -> bytes:
47
+ return self._AESGCM(key).encrypt(iv, plaintext, None)
48
+
49
+ def sign_es256(self, raw_private: bytes, message: bytes) -> bytes:
50
+ priv = self._ec.derive_private_key(int.from_bytes(raw_private, "big"), self._ec.SECP256R1())
51
+ der = priv.sign(message, self._ec.ECDSA(self._hashes.SHA256()))
52
+ r, s = self._decode_dss(der)
53
+ return r.to_bytes(32, "big") + s.to_bytes(32, "big")
54
+
55
+
56
+ def load() -> CryptographyBackend:
57
+ """Return the cryptography backend, or raise BackendUnavailable if absent."""
58
+ try:
59
+ from cryptography.hazmat.primitives import hashes
60
+ from cryptography.hazmat.primitives.asymmetric import ec
61
+ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
62
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
63
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
64
+ except ImportError as exc:
65
+ raise BackendUnavailable("the cryptography package is not installed") from exc
66
+ return CryptographyBackend((ec, AESGCM, Encoding, PublicFormat, hashes, decode_dss_signature))