tina4-python 3.13.68__tar.gz → 3.13.70__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 (169) hide show
  1. {tina4_python-3.13.68 → tina4_python-3.13.70}/PKG-INFO +1 -1
  2. {tina4_python-3.13.68 → tina4_python-3.13.70}/pyproject.toml +1 -1
  3. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/__init__.py +39 -1
  4. tina4_python-3.13.70/tina4_python/api/__init__.py +537 -0
  5. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/cli/__init__.py +12 -2
  6. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/firebird.py +35 -2
  7. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/dev_admin/__init__.py +7 -49
  8. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/dev_admin/metrics.py +1 -1
  9. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/mcp/tools.py +27 -14
  10. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/orm/model.py +51 -2
  11. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/seeder/__init__.py +68 -1
  12. tina4_python-3.13.68/tina4_python/api/__init__.py +0 -273
  13. {tina4_python-3.13.68 → tina4_python-3.13.70}/.gitignore +0 -0
  14. {tina4_python-3.13.68 → tina4_python-3.13.70}/README.md +0 -0
  15. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/CLAUDE.md +0 -0
  16. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/HtmlElement.py +0 -0
  17. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/Testing.py +0 -0
  18. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/ai/__init__.py +0 -0
  19. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/auth/__init__.py +0 -0
  20. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/cache/__init__.py +0 -0
  21. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/container/__init__.py +0 -0
  22. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/context/__init__.py +0 -0
  23. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/context/chunker.py +0 -0
  24. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/__init__.py +0 -0
  25. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/cache.py +0 -0
  26. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/constants.py +0 -0
  27. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/events.py +0 -0
  28. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/middleware.py +0 -0
  29. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/rate_limiter.py +0 -0
  30. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/request.py +0 -0
  31. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/response.py +0 -0
  32. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/router.py +0 -0
  33. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/core/server.py +0 -0
  34. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/crud/__init__.py +0 -0
  35. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/__init__.py +0 -0
  36. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/adapter.py +0 -0
  37. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/connection.py +0 -0
  38. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/mongodb.py +0 -0
  39. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/mssql.py +0 -0
  40. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/mysql.py +0 -0
  41. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/odbc.py +0 -0
  42. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/postgres.py +0 -0
  43. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/database/sqlite.py +0 -0
  44. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/debug/__init__.py +0 -0
  45. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/debug/error_overlay.py +0 -0
  46. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/dev_admin/plan.py +0 -0
  47. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/dev_admin/project_index.py +0 -0
  48. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/docs.py +0 -0
  49. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/docstore/__init__.py +0 -0
  50. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/dotenv/__init__.py +0 -0
  51. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/env.py +0 -0
  52. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/frond/FROND.md +0 -0
  53. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/frond/__init__.py +0 -0
  54. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/frond/engine.py +0 -0
  55. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/auth/meta.json +0 -0
  56. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  57. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/database/meta.json +0 -0
  58. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  59. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/error-overlay/meta.json +0 -0
  60. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  61. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/orm/meta.json +0 -0
  62. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  63. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  64. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/queue/meta.json +0 -0
  65. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  66. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/rest-api/meta.json +0 -0
  67. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  68. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/templates/meta.json +0 -0
  69. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  70. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  71. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/graphql/__init__.py +0 -0
  72. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/i18n/__init__.py +0 -0
  73. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/mcp/__init__.py +0 -0
  74. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/mcp/protocol.py +0 -0
  75. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/messenger/__init__.py +0 -0
  76. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/migration/__init__.py +0 -0
  77. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/migration/runner.py +0 -0
  78. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/orm/__init__.py +0 -0
  79. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/orm/fields.py +0 -0
  80. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/__feedback/widget.js +0 -0
  81. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/css/tina4.css +0 -0
  82. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/css/tina4.min.css +0 -0
  83. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/favicon.ico +0 -0
  84. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/images/logo.svg +0 -0
  85. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  86. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/js/frond.js +0 -0
  87. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/js/frond.min.js +0 -0
  88. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  89. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  90. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/js/tina4.min.js +0 -0
  91. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/js/tina4js.min.js +0 -0
  92. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/swagger/index.html +0 -0
  93. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  94. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/query_builder/__init__.py +0 -0
  95. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue/__init__.py +0 -0
  96. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue/job.py +0 -0
  97. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue/kafka_backend.py +0 -0
  98. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue/lite_backend.py +0 -0
  99. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue/mongo_backend.py +0 -0
  100. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue/rabbitmq_backend.py +0 -0
  101. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue_backends/__init__.py +0 -0
  102. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue_backends/kafka_backend.py +0 -0
  103. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue_backends/mongo_backend.py +0 -0
  104. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  105. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/realtime/__init__.py +0 -0
  106. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/realtime/models/Attachment.py +0 -0
  107. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/realtime/models/Channel.py +0 -0
  108. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/realtime/models/ChannelMember.py +0 -0
  109. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/realtime/models/Message.py +0 -0
  110. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/realtime/models/Workspace.py +0 -0
  111. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/realtime/models/__init__.py +0 -0
  112. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/realtime/storage.py +0 -0
  113. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/__init__.py +0 -0
  114. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  115. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_badges.scss +0 -0
  116. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  117. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_cards.scss +0 -0
  118. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_forms.scss +0 -0
  119. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_grid.scss +0 -0
  120. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_modals.scss +0 -0
  121. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_nav.scss +0 -0
  122. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_reset.scss +0 -0
  123. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_tables.scss +0 -0
  124. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_typography.scss +0 -0
  125. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  126. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_variables.scss +0 -0
  127. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/base.scss +0 -0
  128. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/colors.scss +0 -0
  129. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/scss/tina4css/tina4.scss +0 -0
  130. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/service/__init__.py +0 -0
  131. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/session/__init__.py +0 -0
  132. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/session_handlers/__init__.py +0 -0
  133. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  134. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/session_handlers/redis_handler.py +0 -0
  135. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/session_handlers/valkey_handler.py +0 -0
  136. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/swagger/__init__.py +0 -0
  137. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/components/crud.twig +0 -0
  138. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  139. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  140. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/docker/python/Dockerfile +0 -0
  141. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  142. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/errors/302.twig +0 -0
  143. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/errors/401.twig +0 -0
  144. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/errors/403.twig +0 -0
  145. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/errors/404.twig +0 -0
  146. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/errors/500.twig +0 -0
  147. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/errors/502.twig +0 -0
  148. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/errors/503.twig +0 -0
  149. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/errors/base.twig +0 -0
  150. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/frontend/README.md +0 -0
  151. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/templates/readme.md +0 -0
  152. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/test/__init__.py +0 -0
  153. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/test_client/__init__.py +0 -0
  154. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  161. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  162. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  163. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  164. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  165. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  166. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/validator/__init__.py +0 -0
  167. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/websocket/__init__.py +0 -0
  168. {tina4_python-3.13.68 → tina4_python-3.13.70}/tina4_python/websocket/backplane.py +0 -0
  169. {tina4_python-3.13.68 → tina4_python-3.13.70}/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.68
3
+ Version: 3.13.70
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.68"
3
+ version = "3.13.70"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -8,7 +8,45 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.68"
11
+ def _resolve_version() -> str:
12
+ """Resolve the package version from a single source of truth.
13
+
14
+ Derive it rather than hardcode it, so a release only has to bump
15
+ ``pyproject.toml`` and can never leave this constant stale (which used to
16
+ make the dev toolbar and ``docs._detect_version`` under-report). Mirrors how
17
+ Node reads package.json and PHP resolves ``App::$VERSION`` at runtime.
18
+
19
+ Order:
20
+ 1. The repo's own ``pyproject.toml`` (a source checkout is authoritative
21
+ and current; installed metadata can lag an un-synced checkout). Guarded
22
+ by the project name so a copy vendored under another app's pyproject
23
+ can't hijack the version.
24
+ 2. Installed distribution metadata (the shipped wheel has no pyproject).
25
+ 3. A floor literal (last resort only).
26
+ """
27
+ try:
28
+ import pathlib
29
+ import tomllib
30
+ pyproject = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml"
31
+ if pyproject.exists():
32
+ with pyproject.open("rb") as fh:
33
+ project = tomllib.load(fh).get("project", {})
34
+ if project.get("name") == "tina4-python" and project.get("version"):
35
+ return project["version"]
36
+ except Exception:
37
+ pass
38
+ try:
39
+ from importlib.metadata import PackageNotFoundError, version
40
+ try:
41
+ return version("tina4-python")
42
+ except PackageNotFoundError:
43
+ pass
44
+ except Exception:
45
+ pass
46
+ return "3.13.56"
47
+
48
+
49
+ __version__ = _resolve_version()
12
50
 
13
51
  # ── Route decorators ──
14
52
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -0,0 +1,537 @@
1
+ # Tina4 API Client — HTTP client using Python stdlib only.
2
+ """
3
+ Make HTTP requests without requests/httpx/aiohttp.
4
+
5
+ from tina4_python.api import Api
6
+
7
+ api = Api("https://api.example.com")
8
+ result = api.get("/users")
9
+ result = api.post("/users", {"name": "Alice"})
10
+
11
+ Multipart upload (from disk or in-memory bytes), streaming download,
12
+ an injectable transport seam (for USERS to unit-test their own code),
13
+ and an opt-in per-client cookie jar are all built on the same zero-dependency
14
+ urllib core.
15
+ """
16
+ import os
17
+ import json
18
+ import ssl
19
+ import time
20
+ import base64
21
+ import secrets
22
+ import mimetypes
23
+ from urllib.parse import urlencode, urlparse
24
+ from urllib.request import Request, HTTPRedirectHandler, HTTPSHandler, build_opener
25
+ from urllib.error import HTTPError, URLError
26
+
27
+
28
+ # Statuses that warrant an automatic retry when ``max_retries`` > 0: rate-limit
29
+ # (429) plus the transient server-side 5xx family. 4xx client errors (401,
30
+ # 404, …) are NOT retried — a repeat won't succeed.
31
+ _RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
32
+
33
+ # Streaming download reads/writes this many bytes per chunk so a multi-megabyte
34
+ # body never lands in memory in one piece.
35
+ _DOWNLOAD_CHUNK_SIZE = 64 * 1024
36
+
37
+ # Headers dropped when a redirect crosses to a different origin — a bearer token
38
+ # or a session cookie must never be handed to a host you didn't authenticate to.
39
+ _STRIP_ON_CROSS_ORIGIN = frozenset({"authorization", "cookie"})
40
+
41
+
42
+ def _same_origin(url_a: str, url_b: str) -> bool:
43
+ """True when two URLs share scheme + host + (effective) port."""
44
+ a, b = urlparse(url_a), urlparse(url_b)
45
+ default = {"http": 80, "https": 443}
46
+ pa = a.port if a.port is not None else default.get(a.scheme)
47
+ pb = b.port if b.port is not None else default.get(b.scheme)
48
+ return (a.scheme, a.hostname, pa) == (b.scheme, b.hostname, pb)
49
+
50
+
51
+ class _AuthStripRedirectHandler(HTTPRedirectHandler):
52
+ """Follow redirects, but drop the Authorization (and Cookie) header on a
53
+ cross-origin hop.
54
+
55
+ Plain urllib forwards the Authorization header to ANY redirect target,
56
+ including a different host — so an ``api.get("/login")`` that 302s to
57
+ ``https://attacker.example/`` would hand the bearer token to the attacker.
58
+ Stripping it when the target origin (scheme/host/port) differs matches
59
+ requests/httpx and closes that leak, while same-origin redirects keep auth.
60
+ The cookie jar's ``Cookie`` header is stripped on the same rule for the
61
+ identical reason.
62
+ """
63
+
64
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
65
+ new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
66
+ if new_req is not None and not _same_origin(req.full_url, newurl):
67
+ new_req.headers = {
68
+ k: v for k, v in new_req.headers.items()
69
+ if k.lower() not in _STRIP_ON_CROSS_ORIGIN
70
+ }
71
+ new_req.unredirected_hdrs = {
72
+ k: v for k, v in getattr(new_req, "unredirected_hdrs", {}).items()
73
+ if k.lower() not in _STRIP_ON_CROSS_ORIGIN
74
+ }
75
+ return new_req
76
+
77
+
78
+ def _open(req, timeout, opener):
79
+ """The single network-call indirection point (keeps the call site easy to
80
+ patch in tests). ``req`` stays the first positional arg on purpose."""
81
+ return opener.open(req, timeout=timeout)
82
+
83
+
84
+ class Api:
85
+ """HTTP client using urllib — zero external dependencies."""
86
+
87
+ def __init__(self, base_url: str = "", auth_header: str = "",
88
+ ignore_ssl: bool = False, timeout: int = 30,
89
+ bearer_token: str | None = None,
90
+ username: str | None = None,
91
+ password: str | None = None,
92
+ headers: dict[str, str] | None = None,
93
+ verify_ssl: bool | None = None,
94
+ max_retries: int = 0,
95
+ retry_backoff: float = 0.5,
96
+ transport=None,
97
+ cookies: bool = False):
98
+ """HTTP client.
99
+
100
+ Constructor accepts ergonomic kwargs the documentation has long
101
+ described — every modern Python HTTP library (requests, httpx)
102
+ accepts these directly rather than requiring post-construction
103
+ setter calls.
104
+
105
+ api = Api("https://api.example.com", bearer_token="sk-...")
106
+ api = Api("https://api.example.com", username="u", password="p")
107
+ api = Api("https://api.example.com", headers={"X-Tenant": "acme"})
108
+ api = Api("https://self-signed.local", verify_ssl=False)
109
+
110
+ The setter-based API (``set_bearer_token``, ``set_basic_auth``,
111
+ ``add_headers``) continues to work; pick whichever reads better.
112
+
113
+ ``verify_ssl`` is the docs-friendly inverse of ``ignore_ssl`` —
114
+ ``verify_ssl=False`` is equivalent to ``ignore_ssl=True``. If
115
+ both are supplied, ``ignore_ssl`` wins (legacy precedence).
116
+
117
+ ``max_retries`` (default 0 = off) enables automatic retry with
118
+ exponential backoff (``retry_backoff`` seconds base, doubling each
119
+ attempt) on a transport error or a retryable status (429/5xx). A
120
+ retried non-idempotent request (POST/…) may be re-sent — retries are
121
+ opt-in for that reason.
122
+
123
+ ``transport`` (default None = the real urllib network path) is an
124
+ injectable seam so that USERS can unit-test their own code without a
125
+ live server. When supplied it must be a callable with the signature
126
+ ``transport(method, url, headers, body, timeout)`` returning the same
127
+ result dict every verb returns: ``{"http_code", "body", "headers",
128
+ "error"}``. It fully REPLACES the network call.
129
+
130
+ NOTE: Tina4's own test suite must NEVER inject a fake/canned transport
131
+ — the no-mock rule stands, so framework tests always exercise the real
132
+ network path against a real local server. The seam exists purely so
133
+ *application* developers can test code that calls an ``Api`` instance.
134
+
135
+ ``cookies`` (default False = off, zero behaviour change) turns on a
136
+ per-client, in-memory cookie jar: ``Set-Cookie`` headers on responses
137
+ are parsed and the accumulated ``Cookie`` header is sent on subsequent
138
+ requests. The jar is not persisted and is scoped to this instance.
139
+ """
140
+ self.base_url = base_url.rstrip("/")
141
+ self.auth_header = auth_header
142
+ self.timeout = timeout
143
+ self.max_retries = max(0, int(max_retries))
144
+ self.retry_backoff = retry_backoff
145
+ self._headers: dict[str, str] = {}
146
+ self._ssl_context = None
147
+ self._opener_cache = None
148
+ self._transport = transport
149
+ self._cookies_enabled = bool(cookies)
150
+ self._cookies: dict[str, str] = {}
151
+
152
+ # ── kwarg sugar ────────────────────────────────────────────────
153
+ # Bearer token wins over basic auth if both are passed.
154
+ if bearer_token is not None:
155
+ self.set_bearer_token(bearer_token)
156
+ elif username is not None and password is not None:
157
+ self.set_basic_auth(username, password)
158
+
159
+ if headers:
160
+ self._headers.update(headers)
161
+
162
+ # ignore_ssl is the existing flag; verify_ssl=False is the same thing
163
+ # expressed positively. Honour ignore_ssl when both are set.
164
+ if ignore_ssl or (verify_ssl is False):
165
+ self._ssl_context = ssl.create_default_context()
166
+ self._ssl_context.check_hostname = False
167
+ self._ssl_context.verify_mode = ssl.CERT_NONE
168
+
169
+ def add_headers(self, headers: dict[str, str]):
170
+ """Add custom headers to all requests."""
171
+ self._headers.update(headers)
172
+
173
+
174
+ def set_basic_auth(self, username: str, password: str):
175
+ """Set Basic authentication."""
176
+ creds = base64.b64encode(f"{username}:{password}".encode()).decode()
177
+ self.auth_header = f"Basic {creds}"
178
+
179
+ def set_bearer_token(self, token: str):
180
+ """Set Bearer token authentication."""
181
+ self.auth_header = f"Bearer {token}"
182
+
183
+ def get(self, path: str = "", params: dict = None) -> dict:
184
+ """HTTP GET request."""
185
+ url = self._url(path)
186
+ if params:
187
+ url += "?" + urlencode(params)
188
+ return self._request("GET", url)
189
+
190
+ def post(self, path: str = "", body=None, content_type: str = "application/json") -> dict:
191
+ """HTTP POST request."""
192
+ return self._request("POST", self._url(path), body, content_type)
193
+
194
+ def put(self, path: str = "", body=None, content_type: str = "application/json") -> dict:
195
+ """HTTP PUT request."""
196
+ return self._request("PUT", self._url(path), body, content_type)
197
+
198
+ def patch(self, path: str = "", body=None, content_type: str = "application/json") -> dict:
199
+ """HTTP PATCH request."""
200
+ return self._request("PATCH", self._url(path), body, content_type)
201
+
202
+ def delete(self, path: str = "", body=None) -> dict:
203
+ """HTTP DELETE request."""
204
+ return self._request("DELETE", self._url(path), body)
205
+
206
+ def send(self, method: str, path: str = "", body=None,
207
+ content_type: str = "application/json") -> dict:
208
+ """Generic request method — pick HTTP verb at call time.
209
+
210
+ Renamed from ``send_request`` in 3.13.0 for parity with the
211
+ documentation and conciseness (``api.send("PATCH", ...)`` reads
212
+ cleaner than ``api.send_request("PATCH", ...)``).
213
+ """
214
+ return self._request(method.upper(), self._url(path), body, content_type)
215
+
216
+ def upload(self, path: str = "", file_path: str | None = None,
217
+ field_name: str = "file", extra_fields: dict | None = None,
218
+ headers: dict[str, str] | None = None,
219
+ file_bytes: bytes | None = None,
220
+ filename: str | None = None) -> dict:
221
+ """POST a ``multipart/form-data`` body — a file plus optional text fields.
222
+
223
+ Two ways to supply the file, so a caller never needs a temp file:
224
+
225
+ - ``file_path`` — a file on disk. ``filename`` defaults to its basename.
226
+ - ``file_bytes`` + ``filename`` — an in-memory payload (bytes/str).
227
+
228
+ ``field_name`` is the form field the file is sent under (default
229
+ ``"file"``). ``extra_fields`` (dict) become additional text parts.
230
+ ``headers`` (dict) are extra per-call headers merged onto the request.
231
+ The part's ``Content-Type`` is guessed from the filename via
232
+ ``mimetypes`` (falling back to ``application/octet-stream``).
233
+
234
+ Returns the standard result dict ``{"http_code", "body", "headers",
235
+ "error"}``. A missing file or no source given returns a clean error
236
+ dict (``http_code`` None, ``error`` set) — it does NOT raise.
237
+
238
+ api.upload("/avatars", file_path="/tmp/me.png")
239
+ api.upload("/avatars", file_bytes=raw, filename="me.png",
240
+ extra_fields={"user_id": "42"})
241
+ """
242
+ if file_bytes is not None:
243
+ content = (file_bytes if isinstance(file_bytes, (bytes, bytearray))
244
+ else str(file_bytes).encode("utf-8"))
245
+ upload_name = filename or "upload.bin"
246
+ elif file_path:
247
+ if not os.path.isfile(file_path):
248
+ return {"http_code": None, "body": None, "headers": {},
249
+ "error": f"file not found: {file_path}"}
250
+ try:
251
+ with open(file_path, "rb") as file_handle:
252
+ content = file_handle.read()
253
+ except OSError as read_error:
254
+ return {"http_code": None, "body": None, "headers": {},
255
+ "error": str(read_error)}
256
+ upload_name = filename or os.path.basename(file_path)
257
+ else:
258
+ return {"http_code": None, "body": None, "headers": {},
259
+ "error": "upload requires file_path or file_bytes"}
260
+
261
+ part_content_type = mimetypes.guess_type(upload_name)[0] or "application/octet-stream"
262
+ boundary = "----Tina4Boundary" + secrets.token_hex(16)
263
+ body = self._build_multipart_body(
264
+ boundary, field_name, upload_name, bytes(content),
265
+ part_content_type, extra_fields)
266
+ content_type = f"multipart/form-data; boundary={boundary}"
267
+ return self._request("POST", self._url(path), body, content_type,
268
+ extra_headers=headers)
269
+
270
+ def download(self, path: str = "", dest_path: str | None = None,
271
+ params: dict | None = None) -> dict:
272
+ """Stream a GET response body to ``dest_path`` in chunks.
273
+
274
+ The body is written to disk ``_DOWNLOAD_CHUNK_SIZE`` bytes at a time
275
+ instead of being buffered whole in memory — safe for large payloads.
276
+ Uses the same opener as every other verb (redirect following, the
277
+ cross-origin auth strip, and the SSL context all apply).
278
+
279
+ Returns ``{"http_code", "headers", "error", "path"}`` — there is no
280
+ ``"body"`` key (it went to disk). ``path`` is ``dest_path`` on success
281
+ and ``None`` on any error (missing dest, HTTP error status, or a
282
+ transport failure), and the destination file is not written on error.
283
+ """
284
+ if not dest_path:
285
+ return {"http_code": None, "headers": {},
286
+ "error": "download requires dest_path", "path": None}
287
+
288
+ url = self._url(path)
289
+ if params:
290
+ url += "?" + urlencode(params)
291
+ req = self._build_request("GET", url, None, "application/json")
292
+
293
+ # An injected transport can't stream (it returns a buffered result), so
294
+ # write its body out; only the real urllib path streams chunk-by-chunk.
295
+ if self._transport is not None:
296
+ result = self._call_transport(req)
297
+ self._store_cookies(result["headers"])
298
+ code = result["http_code"]
299
+ if result["error"] is None and code is not None and 200 <= code < 300:
300
+ data = result["body"]
301
+ if isinstance(data, str):
302
+ data = data.encode("utf-8")
303
+ elif not isinstance(data, (bytes, bytearray)):
304
+ data = json.dumps(data, default=str).encode("utf-8")
305
+ try:
306
+ with open(dest_path, "wb") as out_file:
307
+ out_file.write(data)
308
+ except OSError as write_error:
309
+ return {"http_code": code, "headers": result["headers"],
310
+ "error": str(write_error), "path": None}
311
+ return {"http_code": code, "headers": result["headers"],
312
+ "error": None, "path": dest_path}
313
+ return {"http_code": code, "headers": result["headers"],
314
+ "error": result["error"] or f"download failed (HTTP {code})",
315
+ "path": None}
316
+
317
+ try:
318
+ resp = _open(req, self.timeout, self._opener())
319
+ self._store_cookies(resp.headers)
320
+ with open(dest_path, "wb") as out_file:
321
+ while True:
322
+ chunk = resp.read(_DOWNLOAD_CHUNK_SIZE)
323
+ if not chunk:
324
+ break
325
+ out_file.write(chunk)
326
+ return {"http_code": resp.status, "headers": dict(resp.headers),
327
+ "error": None, "path": dest_path}
328
+ except HTTPError as http_error:
329
+ return {"http_code": http_error.code,
330
+ "headers": dict(http_error.headers) if http_error.headers else {},
331
+ "error": str(http_error), "path": None}
332
+ except URLError as url_error:
333
+ return {"http_code": None, "headers": {},
334
+ "error": str(url_error.reason), "path": None}
335
+ except Exception as exc:
336
+ return {"http_code": None, "headers": {}, "error": str(exc), "path": None}
337
+
338
+ def _url(self, path: str) -> str:
339
+ if path.startswith("http"):
340
+ return path
341
+ return f"{self.base_url}/{path.lstrip('/')}" if path else self.base_url
342
+
343
+ def _opener(self):
344
+ """Build (once) an opener that follows redirects but strips the
345
+ Authorization/Cookie header on a cross-origin hop, honouring the SSL
346
+ context."""
347
+ if self._opener_cache is None:
348
+ handlers = [_AuthStripRedirectHandler()]
349
+ if self._ssl_context is not None:
350
+ handlers.append(HTTPSHandler(context=self._ssl_context))
351
+ self._opener_cache = build_opener(*handlers)
352
+ return self._opener_cache
353
+
354
+ @staticmethod
355
+ def _build_multipart_body(boundary: str, field_name: str, filename: str,
356
+ file_content: bytes, content_type: str,
357
+ extra_fields: dict | None) -> bytes:
358
+ """Assemble a ``multipart/form-data`` body as raw bytes.
359
+
360
+ Text fields come first, then the file part, then the closing delimiter
361
+ — matching the canonical Ruby ``build_multipart_body`` shape so every
362
+ framework produces a byte-identical layout.
363
+ """
364
+ crlf = b"\r\n"
365
+ delimiter = ("--" + boundary).encode("utf-8")
366
+ chunks: list[bytes] = []
367
+ for key, value in (extra_fields or {}).items():
368
+ chunks.append(delimiter + crlf)
369
+ chunks.append(f'Content-Disposition: form-data; name="{key}"'.encode("utf-8")
370
+ + crlf + crlf)
371
+ chunks.append(str(value).encode("utf-8") + crlf)
372
+ chunks.append(delimiter + crlf)
373
+ disposition = f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"'
374
+ chunks.append(disposition.encode("utf-8") + crlf)
375
+ chunks.append(f"Content-Type: {content_type}".encode("utf-8") + crlf + crlf)
376
+ chunks.append(file_content + crlf)
377
+ chunks.append(delimiter + b"--" + crlf)
378
+ return b"".join(chunks)
379
+
380
+ def _build_request(self, method: str, url: str, body, content_type: str,
381
+ extra_headers: dict | None = None) -> Request:
382
+ headers = dict(self._headers)
383
+ if self.auth_header:
384
+ headers["Authorization"] = self.auth_header
385
+
386
+ # Cookie jar: attach the accumulated Cookie header when enabled.
387
+ if self._cookies_enabled:
388
+ cookie_header = self._cookie_header()
389
+ if cookie_header:
390
+ headers["Cookie"] = cookie_header
391
+
392
+ data = None
393
+ if body is not None:
394
+ if content_type == "application/json" and isinstance(body, (dict, list)):
395
+ data = json.dumps(body, default=str).encode("utf-8")
396
+ headers["Content-Type"] = "application/json"
397
+ elif isinstance(body, str):
398
+ data = body.encode("utf-8")
399
+ headers["Content-Type"] = content_type
400
+ elif isinstance(body, bytes):
401
+ data = body
402
+ headers["Content-Type"] = content_type
403
+
404
+ if extra_headers:
405
+ headers.update(extra_headers)
406
+
407
+ return Request(url, data=data, headers=headers, method=method)
408
+
409
+ def _request(self, method: str, url: str, body=None,
410
+ content_type: str = "application/json",
411
+ extra_headers: dict | None = None) -> dict:
412
+ """Execute the request with opt-in retry/backoff. Returns a result dict.
413
+
414
+ With ``max_retries`` > 0, a transport failure (``http_code`` None) or a
415
+ retryable status (429/5xx) is retried up to ``max_retries`` times with
416
+ exponential backoff; any other outcome (2xx, 4xx, 3xx) returns at once.
417
+ """
418
+ req = self._build_request(method, url, body, content_type, extra_headers)
419
+ attempts = self.max_retries + 1
420
+ result = None
421
+ for attempt in range(attempts):
422
+ result = self._attempt(req)
423
+ code = result.get("http_code")
424
+ retryable = code is None or code in _RETRY_STATUSES
425
+ if not retryable or attempt == attempts - 1:
426
+ return result
427
+ time.sleep(self.retry_backoff * (2 ** attempt))
428
+ return result
429
+
430
+ def _call_transport(self, req: Request) -> dict:
431
+ """Invoke a user-injected transport and normalize its result dict.
432
+
433
+ The transport fully replaces the network call; it is called with the
434
+ request as ``(method, url, headers, body, timeout)``.
435
+ """
436
+ try:
437
+ result = self._transport(
438
+ req.get_method(),
439
+ req.full_url,
440
+ dict(req.header_items()),
441
+ req.data,
442
+ self.timeout,
443
+ )
444
+ except Exception as exc:
445
+ return {"http_code": None, "body": None, "headers": {}, "error": str(exc)}
446
+ return {
447
+ "http_code": result.get("http_code"),
448
+ "body": result.get("body"),
449
+ "headers": result.get("headers") or {},
450
+ "error": result.get("error"),
451
+ }
452
+
453
+ def _attempt(self, req: Request) -> dict:
454
+ """A single HTTP attempt. Returns the standardized result dict."""
455
+ if self._transport is not None:
456
+ result = self._call_transport(req)
457
+ self._store_cookies(result["headers"])
458
+ return result
459
+ try:
460
+ resp = _open(req, self.timeout, self._opener())
461
+ self._store_cookies(resp.headers)
462
+ raw = resp.read().decode("utf-8", errors="replace")
463
+ resp_headers = dict(resp.headers)
464
+ try:
465
+ parsed = json.loads(raw)
466
+ except (json.JSONDecodeError, ValueError):
467
+ parsed = raw
468
+
469
+ return {
470
+ "http_code": resp.status,
471
+ "body": parsed,
472
+ "headers": resp_headers,
473
+ "error": None,
474
+ }
475
+ except HTTPError as e:
476
+ self._store_cookies(e.headers)
477
+ raw = e.read().decode("utf-8", errors="replace") if e.fp else ""
478
+ try:
479
+ parsed = json.loads(raw)
480
+ except (json.JSONDecodeError, ValueError):
481
+ parsed = raw
482
+ return {
483
+ "http_code": e.code,
484
+ "body": parsed,
485
+ "headers": dict(e.headers) if e.headers else {},
486
+ "error": str(e),
487
+ }
488
+ except URLError as e:
489
+ return {
490
+ "http_code": None,
491
+ "body": None,
492
+ "headers": {},
493
+ "error": str(e.reason),
494
+ }
495
+ except Exception as e:
496
+ return {
497
+ "http_code": None,
498
+ "body": None,
499
+ "headers": {},
500
+ "error": str(e),
501
+ }
502
+
503
+ # ── cookie jar (opt-in, in-memory, per-client) ─────────────────────────
504
+ def _cookie_header(self) -> str | None:
505
+ """The accumulated ``Cookie`` request header, or None when empty."""
506
+ if not self._cookies:
507
+ return None
508
+ return "; ".join(f"{name}={value}" for name, value in self._cookies.items())
509
+
510
+ def _store_cookies(self, headers) -> None:
511
+ """Parse ``Set-Cookie`` response headers into the jar (when enabled).
512
+
513
+ Only the leading ``name=value`` pair of each ``Set-Cookie`` header is
514
+ kept (attributes like Path/HttpOnly/Expires are ignored); a later value
515
+ for the same name overwrites an earlier one. ``headers`` may be an
516
+ ``http.client.HTTPMessage`` (real path — supports multiple
517
+ ``Set-Cookie`` via ``get_all``) or a plain dict (transport seam).
518
+ """
519
+ if not self._cookies_enabled or headers is None:
520
+ return
521
+ raw_values: list[str] = []
522
+ if hasattr(headers, "get_all"):
523
+ raw_values = headers.get_all("Set-Cookie") or []
524
+ elif isinstance(headers, dict):
525
+ for key, value in headers.items():
526
+ if key.lower() == "set-cookie" and value:
527
+ raw_values.append(value)
528
+ for raw in raw_values:
529
+ first_pair = raw.split(";", 1)[0].strip()
530
+ if "=" in first_pair:
531
+ name, _, value = first_pair.partition("=")
532
+ name = name.strip()
533
+ if name:
534
+ self._cookies[name] = value.strip()
535
+
536
+
537
+ __all__ = ["Api"]
@@ -311,16 +311,26 @@ def _console(args=None):
311
311
 
312
312
  # Import everything the user needs
313
313
  from tina4_python import get, post, put, patch, delete, Router, Database, ORM, Auth, Queue, Frond
314
+ from tina4_python.orm import bind_database
314
315
  from tina4_python.debug import Log
315
316
  from tina4_python.api import Api
316
317
  from tina4_python.core.events import on, emit
317
318
 
318
- # Try to connect database from TINA4_DATABASE_URL
319
+ # Try to connect database from TINA4_DATABASE_URL. Honour the SEPARATE
320
+ # credential env vars (TINA4_DATABASE_USERNAME/PASSWORD) — the documented
321
+ # Tina4 convention keeps credentials out of the URL, so building the handle
322
+ # from the URL alone left `db` unauthenticated and it failed on every
323
+ # credentialed engine (PostgreSQL/MySQL/Firebird/MSSQL). Bind it globally
324
+ # too, so the `db` handle and the ORM models share ONE connected instance
325
+ # (mirrors ORM._get_db()'s own lazy auto-bind).
319
326
  db = None
320
327
  db_url = os.environ.get("TINA4_DATABASE_URL")
321
328
  if db_url:
322
329
  try:
323
- db = Database(db_url)
330
+ username = os.environ.get("TINA4_DATABASE_USERNAME", "")
331
+ password = os.environ.get("TINA4_DATABASE_PASSWORD", "")
332
+ db = Database(db_url, username, password)
333
+ bind_database(db)
324
334
  print(f" Database: {db_url}")
325
335
  except Exception as e:
326
336
  print(f" Database: failed ({e})")