tina4-python 3.13.135__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.135 → tina4_python-3.13.137}/PKG-INFO +1 -1
  2. {tina4_python-3.13.135 → tina4_python-3.13.137}/pyproject.toml +7 -4
  3. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/ai/client.py +14 -6
  5. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/server.py +5 -1
  6. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/connection.py +58 -40
  7. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/migration/runner.py +23 -9
  8. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/swagger/index.html +1 -1
  9. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/push/__init__.py +59 -35
  10. tina4_python-3.13.137/tina4_python/push/_base.py +38 -0
  11. tina4_python-3.13.137/tina4_python/push/_cryptography.py +66 -0
  12. tina4_python-3.13.137/tina4_python/push/_libcrypto.py +261 -0
  13. {tina4_python-3.13.135 → tina4_python-3.13.137}/.gitignore +0 -0
  14. {tina4_python-3.13.135 → tina4_python-3.13.137}/README.md +0 -0
  15. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/.tina4-metrics.json +0 -0
  16. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/CLAUDE.md +0 -0
  17. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/HtmlElement.py +0 -0
  18. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/Testing.py +0 -0
  19. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/_import_helper.py +0 -0
  20. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/ai/__init__.py +0 -0
  21. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/api/__init__.py +0 -0
  22. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/auth/__init__.py +0 -0
  23. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/cache/__init__.py +0 -0
  24. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/cli/__init__.py +0 -0
  25. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/container/__init__.py +0 -0
  26. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/context/__init__.py +0 -0
  27. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/context/chunker.py +0 -0
  28. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/__init__.py +0 -0
  29. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/cache.py +0 -0
  30. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/constants.py +0 -0
  31. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/events.py +0 -0
  32. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/middleware.py +0 -0
  33. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/port_takeover.py +0 -0
  34. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/rate_limiter.py +0 -0
  35. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/request.py +0 -0
  36. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/response.py +0 -0
  37. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/core/router.py +0 -0
  38. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/crud/__init__.py +0 -0
  39. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/__init__.py +0 -0
  40. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/adapter.py +0 -0
  41. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/database_url.py +0 -0
  42. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/firebird.py +0 -0
  43. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/mongodb.py +0 -0
  44. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/mssql.py +0 -0
  45. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/mysql.py +0 -0
  46. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/odbc.py +0 -0
  47. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/postgres.py +0 -0
  48. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/sql_translator.py +0 -0
  49. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/database/sqlite.py +0 -0
  50. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/debug/__init__.py +0 -0
  51. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/debug/error_overlay.py +0 -0
  52. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/dev_admin/__init__.py +0 -0
  53. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/dev_admin/metrics.py +0 -0
  54. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/dev_admin/plan.py +0 -0
  55. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/dev_admin/project_index.py +0 -0
  56. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/docs.py +0 -0
  57. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/docstore/__init__.py +0 -0
  58. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/dotenv/__init__.py +0 -0
  59. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/env.py +0 -0
  60. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/frond/FROND.md +0 -0
  61. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/frond/__init__.py +0 -0
  62. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/frond/compiler.py +0 -0
  63. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/frond/engine.py +0 -0
  64. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/frond/parser.py +0 -0
  65. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/auth/meta.json +0 -0
  66. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  67. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/database/meta.json +0 -0
  68. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  69. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/error-overlay/meta.json +0 -0
  70. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  71. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/orm/meta.json +0 -0
  72. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  73. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  74. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/queue/meta.json +0 -0
  75. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  76. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/rest-api/meta.json +0 -0
  77. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  78. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/templates/meta.json +0 -0
  79. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  80. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  81. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/graph/__init__.py +0 -0
  82. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/graph/adapter.py +0 -0
  83. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/graph/adapters/__init__.py +0 -0
  84. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/graph/adapters/arango.py +0 -0
  85. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/graph/adapters/bolt.py +0 -0
  86. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/graph/adapters/ultipa.py +0 -0
  87. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/graph/graph_url.py +0 -0
  88. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/graphql/__init__.py +0 -0
  89. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/i18n/__init__.py +0 -0
  90. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/mcp/__init__.py +0 -0
  91. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/mcp/protocol.py +0 -0
  92. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/mcp/tools.py +0 -0
  93. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/messenger/__init__.py +0 -0
  94. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/migration/__init__.py +0 -0
  95. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/mqtt/__init__.py +0 -0
  96. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/mqtt/message.py +0 -0
  97. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/orm/__init__.py +0 -0
  98. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/orm/collection.py +0 -0
  99. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/orm/fields.py +0 -0
  100. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/orm/model.py +0 -0
  101. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/orm/point.py +0 -0
  102. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/__feedback/widget.js +0 -0
  103. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/css/tina4.css +0 -0
  104. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/css/tina4.min.css +0 -0
  105. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/favicon.ico +0 -0
  106. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/images/logo.svg +0 -0
  107. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  108. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/js/frond.js +0 -0
  109. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/js/frond.min.js +0 -0
  110. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  111. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/js/tina4.min.js +0 -0
  112. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/js/tina4js.min.js +0 -0
  113. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  114. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/query_builder/__init__.py +0 -0
  115. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue/__init__.py +0 -0
  116. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue/amqp_url.py +0 -0
  117. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue/job.py +0 -0
  118. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue/kafka_backend.py +0 -0
  119. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue/lite_backend.py +0 -0
  120. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue/mongo_backend.py +0 -0
  121. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue/rabbitmq_backend.py +0 -0
  122. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue_backends/__init__.py +0 -0
  123. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue_backends/kafka_backend.py +0 -0
  124. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue_backends/mongo_backend.py +0 -0
  125. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  126. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/realtime/__init__.py +0 -0
  127. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/realtime/models/Attachment.py +0 -0
  128. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/realtime/models/Channel.py +0 -0
  129. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/realtime/models/ChannelMember.py +0 -0
  130. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/realtime/models/Message.py +0 -0
  131. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/realtime/models/Workspace.py +0 -0
  132. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/realtime/models/__init__.py +0 -0
  133. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/realtime/storage.py +0 -0
  134. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/seeder/__init__.py +0 -0
  135. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/service/__init__.py +0 -0
  136. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/session/__init__.py +0 -0
  137. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/session_handlers/__init__.py +0 -0
  138. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/session_handlers/memcached_handler.py +0 -0
  139. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  140. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/session_handlers/redis_handler.py +0 -0
  141. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/session_handlers/valkey_handler.py +0 -0
  142. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/sso.py +0 -0
  143. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/swagger/__init__.py +0 -0
  144. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/components/crud.twig +0 -0
  145. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  146. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  147. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/docker/python/Dockerfile +0 -0
  148. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  149. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/errors/302.twig +0 -0
  150. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/errors/401.twig +0 -0
  151. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/errors/403.twig +0 -0
  152. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/errors/404.twig +0 -0
  153. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/errors/500.twig +0 -0
  154. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/errors/502.twig +0 -0
  155. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/errors/503.twig +0 -0
  156. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/errors/base.twig +0 -0
  157. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/frontend/README.md +0 -0
  158. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/templates/readme.md +0 -0
  159. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/test/__init__.py +0 -0
  160. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/test_client/__init__.py +0 -0
  161. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  166. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  167. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  168. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  169. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  170. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  171. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  172. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  173. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/validator/__init__.py +0 -0
  174. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/websocket/__init__.py +0 -0
  175. {tina4_python-3.13.135 → tina4_python-3.13.137}/tina4_python/websocket/backplane.py +0 -0
  176. {tina4_python-3.13.135 → 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.135
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.135"
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.135"
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
@@ -10,6 +10,7 @@ Connection pooling:
10
10
  db = Database("sqlite:///data/app.db", pool=4) # 4 connections, round-robin
11
11
  """
12
12
  import hashlib
13
+ import importlib
13
14
  import os
14
15
  import threading
15
16
  import time
@@ -129,43 +130,56 @@ def register_driver(scheme: str, adapter_class: type):
129
130
  _DRIVERS[scheme] = adapter_class
130
131
 
131
132
 
132
- # Register built-in SQLite
133
+ # Register built-in SQLite eagerly. It is stdlib, so importing it drags in no
134
+ # third-party package -- the zero-dependency default carries no import cost.
133
135
  from tina4_python.database.sqlite import SQLiteAdapter
134
136
  register_driver("sqlite", SQLiteAdapter)
135
137
 
136
- # Register ODBC (lazy — only fails if you actually use it without pyodbc)
137
- try:
138
- from tina4_python.database.odbc import ODBCAdapter
139
- register_driver("odbc", ODBCAdapter)
140
- except ImportError:
141
- pass # pyodbc not installed — that's fine
142
-
143
- # Register PostgreSQL (psycopg2 — optional)
144
- from tina4_python.database.postgres import PostgreSQLAdapter
145
- register_driver("postgresql", PostgreSQLAdapter)
146
- register_driver("postgres", PostgreSQLAdapter)
147
- register_driver("pgsql", PostgreSQLAdapter) # PDO / Laravel / Doctrine scheme name (issue #58)
148
-
149
- # Register MySQL (mysql-connector-python — optional)
150
- from tina4_python.database.mysql import MySQLAdapter
151
- register_driver("mysql", MySQLAdapter)
152
-
153
- # Register MSSQL (pymssql — optional)
154
- from tina4_python.database.mssql import MSSQLAdapter
155
- register_driver("mssql", MSSQLAdapter)
156
- register_driver("sqlserver", MSSQLAdapter)
157
-
158
- # Register Firebird (fdb — optional)
159
- from tina4_python.database.firebird import FirebirdAdapter
160
- register_driver("firebird", FirebirdAdapter)
161
-
162
- # Register MongoDB (pymongo — optional)
163
- try:
164
- from tina4_python.database.mongodb import MongoDBAdapter
165
- register_driver("mongodb", MongoDBAdapter)
166
- register_driver("pymongo", MongoDBAdapter)
167
- except ImportError:
168
- pass
138
+ # Every OTHER adapter is registered LAZILY: its module -- and the third-party
139
+ # driver that module imports -- is loaded only when a URL for that scheme is
140
+ # actually connected. Importing tina4_python.database (which happens on the first
141
+ # Database(url), regardless of engine) therefore never drags psycopg2,
142
+ # firebird-driver (+ protobuf + dateutil), pymssql or pymongo into an app that
143
+ # does not use them. Connecting to Postgres loads only psycopg2; a SQLite app
144
+ # loads no third-party driver at all. This mirrors PHP/Ruby autoload and Node's
145
+ # optionalDependencies + dynamic import. Aliases share the same target so the
146
+ # canonical engine and every alias resolve to one adapter class.
147
+ _LAZY_DRIVERS: dict[str, tuple[str, str]] = {
148
+ "postgres": ("tina4_python.database.postgres", "PostgreSQLAdapter"),
149
+ "postgresql": ("tina4_python.database.postgres", "PostgreSQLAdapter"),
150
+ "pgsql": ("tina4_python.database.postgres", "PostgreSQLAdapter"), # PDO / Laravel / Doctrine (issue #58)
151
+ "mysql": ("tina4_python.database.mysql", "MySQLAdapter"),
152
+ "mssql": ("tina4_python.database.mssql", "MSSQLAdapter"),
153
+ "sqlserver": ("tina4_python.database.mssql", "MSSQLAdapter"),
154
+ "firebird": ("tina4_python.database.firebird", "FirebirdAdapter"),
155
+ "odbc": ("tina4_python.database.odbc", "ODBCAdapter"),
156
+ "mongodb": ("tina4_python.database.mongodb", "MongoDBAdapter"),
157
+ "pymongo": ("tina4_python.database.mongodb", "MongoDBAdapter"),
158
+ }
159
+
160
+
161
+ def known_drivers() -> set[str]:
162
+ """Every URL scheme the framework can resolve: eagerly-registered (sqlite,
163
+ plus anything an app registered by hand via register_driver) and the
164
+ lazily-registered built-ins that import their driver on first use."""
165
+ return set(_DRIVERS) | set(_LAZY_DRIVERS)
166
+
167
+
168
+ def _resolve_lazy_driver(scheme: str) -> None:
169
+ """Import a lazily-declared adapter and register it, on first use.
170
+
171
+ Raises a clear ValueError if the adapter's driver package is not installed,
172
+ rather than an opaque ImportError from deep in the driver.
173
+ """
174
+ module_path, class_name = _LAZY_DRIVERS[scheme]
175
+ try:
176
+ module = importlib.import_module(module_path)
177
+ except ImportError as exc:
178
+ raise ValueError(
179
+ f"Database driver '{scheme}' is unavailable: {exc}. "
180
+ f"Install the driver package and it will register automatically."
181
+ ) from exc
182
+ register_driver(scheme, getattr(module, class_name))
169
183
 
170
184
 
171
185
  class Database:
@@ -339,12 +353,16 @@ class Database:
339
353
  scheme = (urlparse(self.url).scheme or "").lower()
340
354
 
341
355
  if scheme not in _DRIVERS:
342
- available = ", ".join(_DRIVERS.keys())
343
- raise ValueError(
344
- f"Unknown database driver '{scheme}'. "
345
- f"Available: {available}. "
346
- f"Install the driver package and it will register automatically."
347
- )
356
+ if scheme in _LAZY_DRIVERS:
357
+ # First connection to this engine: import + register its adapter.
358
+ _resolve_lazy_driver(scheme)
359
+ else:
360
+ available = ", ".join(sorted(known_drivers()))
361
+ raise ValueError(
362
+ f"Unknown database driver '{scheme}'. "
363
+ f"Available: {available}. "
364
+ f"Install the driver package and it will register automatically."
365
+ )
348
366
 
349
367
  return _DRIVERS[scheme]()
350
368
 
@@ -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
 
@@ -71,7 +71,7 @@
71
71
 
72
72
  // Build a system
73
73
  const ui = SwaggerUIBundle({
74
- url: "{SWAGGER_ROUTE}/swagger.json",
74
+ url: "/swagger/openapi.json",
75
75
  dom_id: '#swagger-ui',
76
76
  deepLinking: true,
77
77
  presets: [
@@ -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))