tina4-python 3.13.95__tar.gz → 3.13.97__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 (177) hide show
  1. {tina4_python-3.13.95 → tina4_python-3.13.97}/PKG-INFO +2 -1
  2. {tina4_python-3.13.95 → tina4_python-3.13.97}/pyproject.toml +5 -1
  3. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/CLAUDE.md +1 -1
  4. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/__init__.py +2 -1
  5. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/api/__init__.py +10 -7
  6. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/server.py +198 -11
  7. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/crud/__init__.py +10 -11
  8. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/adapter.py +54 -59
  9. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/messenger/__init__.py +158 -79
  10. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/migration/runner.py +29 -2
  11. tina4_python-3.13.97/tina4_python/public/css/tina4.min.css +1 -0
  12. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/kafka_backend.py +31 -2
  13. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/rabbitmq_backend.py +30 -4
  14. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/swagger/__init__.py +6 -3
  15. tina4_python-3.13.95/tina4_python/public/css/tina4.min.css +0 -1
  16. tina4_python-3.13.95/tina4_python/scss/__init__.py +0 -694
  17. {tina4_python-3.13.95 → tina4_python-3.13.97}/.gitignore +0 -0
  18. {tina4_python-3.13.95 → tina4_python-3.13.97}/README.md +0 -0
  19. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/HtmlElement.py +0 -0
  20. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/Testing.py +0 -0
  21. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/ai/__init__.py +0 -0
  22. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/auth/__init__.py +0 -0
  23. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/cache/__init__.py +0 -0
  24. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/cli/__init__.py +0 -0
  25. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/container/__init__.py +0 -0
  26. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/context/__init__.py +0 -0
  27. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/context/chunker.py +0 -0
  28. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/__init__.py +0 -0
  29. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/cache.py +0 -0
  30. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/constants.py +0 -0
  31. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/events.py +0 -0
  32. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/middleware.py +0 -0
  33. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/rate_limiter.py +0 -0
  34. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/request.py +0 -0
  35. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/response.py +0 -0
  36. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/router.py +0 -0
  37. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/__init__.py +0 -0
  38. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/connection.py +0 -0
  39. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/database_url.py +0 -0
  40. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/firebird.py +0 -0
  41. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/mongodb.py +0 -0
  42. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/mssql.py +0 -0
  43. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/mysql.py +0 -0
  44. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/odbc.py +0 -0
  45. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/postgres.py +0 -0
  46. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/sql_translator.py +0 -0
  47. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/sqlite.py +0 -0
  48. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/debug/__init__.py +0 -0
  49. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/debug/error_overlay.py +0 -0
  50. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dev_admin/__init__.py +0 -0
  51. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dev_admin/metrics.py +0 -0
  52. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dev_admin/plan.py +0 -0
  53. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dev_admin/project_index.py +0 -0
  54. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/docs.py +0 -0
  55. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/docstore/__init__.py +0 -0
  56. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dotenv/__init__.py +0 -0
  57. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/env.py +0 -0
  58. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/FROND.md +0 -0
  59. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/__init__.py +0 -0
  60. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/compiler.py +0 -0
  61. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/engine.py +0 -0
  62. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/parser.py +0 -0
  63. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/auth/meta.json +0 -0
  64. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  65. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/database/meta.json +0 -0
  66. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  67. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/error-overlay/meta.json +0 -0
  68. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  69. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/orm/meta.json +0 -0
  70. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  71. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  72. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/queue/meta.json +0 -0
  73. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  74. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/rest-api/meta.json +0 -0
  75. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  76. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/templates/meta.json +0 -0
  77. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  78. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  79. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/graphql/__init__.py +0 -0
  80. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/i18n/__init__.py +0 -0
  81. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mcp/__init__.py +0 -0
  82. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mcp/protocol.py +0 -0
  83. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mcp/tools.py +0 -0
  84. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/migration/__init__.py +0 -0
  85. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mqtt/__init__.py +0 -0
  86. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mqtt/message.py +0 -0
  87. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/orm/__init__.py +0 -0
  88. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/orm/fields.py +0 -0
  89. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/orm/model.py +0 -0
  90. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/__feedback/widget.js +0 -0
  91. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/css/tina4.css +0 -0
  92. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/favicon.ico +0 -0
  93. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/images/logo.svg +0 -0
  94. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  95. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/frond.js +0 -0
  96. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/frond.min.js +0 -0
  97. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  98. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/tina4.min.js +0 -0
  99. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/tina4js.min.js +0 -0
  100. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/swagger/index.html +0 -0
  101. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  102. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/query_builder/__init__.py +0 -0
  103. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/__init__.py +0 -0
  104. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/amqp_url.py +0 -0
  105. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/job.py +0 -0
  106. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/lite_backend.py +0 -0
  107. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/mongo_backend.py +0 -0
  108. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue_backends/__init__.py +0 -0
  109. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue_backends/kafka_backend.py +0 -0
  110. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue_backends/mongo_backend.py +0 -0
  111. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  112. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/__init__.py +0 -0
  113. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/Attachment.py +0 -0
  114. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/Channel.py +0 -0
  115. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/ChannelMember.py +0 -0
  116. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/Message.py +0 -0
  117. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/Workspace.py +0 -0
  118. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/__init__.py +0 -0
  119. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/storage.py +0 -0
  120. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  121. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_badges.scss +0 -0
  122. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  123. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_cards.scss +0 -0
  124. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_forms.scss +0 -0
  125. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_grid.scss +0 -0
  126. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_modals.scss +0 -0
  127. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_nav.scss +0 -0
  128. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_pagination.scss +0 -0
  129. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_reset.scss +0 -0
  130. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_tables.scss +0 -0
  131. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_typography.scss +0 -0
  132. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  133. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_variables.scss +0 -0
  134. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/base.scss +0 -0
  135. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/colors.scss +0 -0
  136. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/tina4.scss +0 -0
  137. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/seeder/__init__.py +0 -0
  138. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/service/__init__.py +0 -0
  139. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session/__init__.py +0 -0
  140. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/__init__.py +0 -0
  141. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/memcached_handler.py +0 -0
  142. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  143. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/redis_handler.py +0 -0
  144. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/valkey_handler.py +0 -0
  145. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/components/crud.twig +0 -0
  146. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  147. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  148. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/docker/python/Dockerfile +0 -0
  149. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  150. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/302.twig +0 -0
  151. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/401.twig +0 -0
  152. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/403.twig +0 -0
  153. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/404.twig +0 -0
  154. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/500.twig +0 -0
  155. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/502.twig +0 -0
  156. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/503.twig +0 -0
  157. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/base.twig +0 -0
  158. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/frontend/README.md +0 -0
  159. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/readme.md +0 -0
  160. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/test/__init__.py +0 -0
  161. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/test_client/__init__.py +0 -0
  162. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  163. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  164. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  165. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  166. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  167. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  168. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  169. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  170. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  171. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  172. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  173. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  174. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/validator/__init__.py +0 -0
  175. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/websocket/__init__.py +0 -0
  176. {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/websocket/backplane.py +0 -0
  177. {tina4_python-3.13.95 → tina4_python-3.13.97}/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.95
3
+ Version: 3.13.97
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
@@ -39,6 +39,7 @@ Requires-Dist: boto3>=1.34; extra == 'test'
39
39
  Requires-Dist: confluent-kafka>=2.0; extra == 'test'
40
40
  Requires-Dist: firebird-driver>=1.10; extra == 'test'
41
41
  Requires-Dist: mysql-connector-python>=9.0; extra == 'test'
42
+ Requires-Dist: openapi-spec-validator>=0.7; extra == 'test'
42
43
  Requires-Dist: pika>=1.3; extra == 'test'
43
44
  Requires-Dist: psycopg2-binary>=2.9; extra == 'test'
44
45
  Requires-Dist: pymemcache>=4.0; extra == 'test'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.95"
3
+ version = "3.13.97"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -81,6 +81,10 @@ test = [
81
81
  "confluent-kafka>=2.0",
82
82
  "pika>=1.3",
83
83
  "boto3>=1.34",
84
+ # TEST-ONLY: validates the generated OpenAPI document against the real
85
+ # OpenAPI 3.0/3.1 schema (tests/test_swagger_contract.py). NEVER a framework
86
+ # runtime dependency — the swagger generator itself stays zero-dependency.
87
+ "openapi-spec-validator>=0.7",
84
88
  ]
85
89
 
86
90
  [dependency-groups]
@@ -821,7 +821,7 @@ row = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
821
821
  # Result methods
822
822
  result.to_json() # JSON string
823
823
  result.to_array() # List of dicts
824
- result.to_paginate() # Dict with records, count, limit, offset
824
+ result.to_paginate() # Dict: records, total, page, per_page, total_pages, limit, offset (takes no args; ADR-0043)
825
825
  result.to_csv() # CSV string
826
826
 
827
827
  # Transactions
@@ -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.95"
54
+ return "3.13.97"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -86,6 +86,7 @@ from tina4_python.core.constants import ( # noqa: E402, F401
86
86
  )
87
87
  from tina4_python.core.server import ( # noqa: E402, F401
88
88
  run, background, background_task_count, stop_all_background_tasks,
89
+ asgi,
89
90
  )
90
91
  from tina4_python.core.events import on, emit, once, off # noqa: E402, F401
91
92
  from tina4_python.env import Env # noqa: E402, F401
@@ -203,13 +203,16 @@ class Api:
203
203
  """HTTP DELETE request."""
204
204
  return self._request("DELETE", self._url(path), body)
205
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", ...)``).
206
+ def send_request(self, method: str, path: str = "", body=None,
207
+ content_type: str = "application/json") -> dict:
208
+ """Generic request method — pick the HTTP verb at call time.
209
+
210
+ ``send_request`` is the name all four frameworks share (PHP
211
+ ``sendRequest``, Ruby ``send_request``, Node ``sendRequest``). The
212
+ 3.13.0 Python-only rename to ``send`` was reverted: ``send`` can never
213
+ be the shared name because Ruby's ``send`` is ``Object#send``, the
214
+ metaprogramming primitive, so a Ruby class cannot expose an HTTP
215
+ ``send`` without shadowing it. One concept, one name, everywhere.
213
216
  """
214
217
  return self._request(method.upper(), self._url(path), body, content_type)
215
218
 
@@ -17,7 +17,11 @@ import uuid
17
17
  from email.utils import formatdate, parsedate_to_datetime
18
18
  from pathlib import Path
19
19
 
20
- from tina4_python.core.request import Request
20
+ from tina4_python.core.request import (
21
+ Request,
22
+ PayloadTooLarge,
23
+ TINA4_MAX_UPLOAD_SIZE,
24
+ )
21
25
  from tina4_python.core.response import Response
22
26
  from tina4_python.core.router import Router
23
27
  from tina4_python.core.middleware import CorsMiddleware, RateLimiter
@@ -1867,10 +1871,42 @@ async def _invoke_handler(request: Request, response: Response, route: dict, par
1867
1871
  _args.append(request)
1868
1872
  _args.append(response)
1869
1873
 
1870
- if _pcount == 0:
1871
- result = await route["handler"]()
1874
+ # A SYNC HANDLER RUNS IN A THREAD, AN ASYNC ONE RUNS ON THE LOOP.
1875
+ #
1876
+ # This used to `await` the handler unconditionally, which had two costs.
1877
+ # A plain `def` handler died with "object Response can't be used in 'await'
1878
+ # expression" - a message naming neither the handler nor the rule - even
1879
+ # though Flask, Django and FastAPI all accept a sync view, so `def` is the
1880
+ # first thing a Python developer writes. And there was nowhere for blocking
1881
+ # work to go: asyncio runs ONE loop, so anything that blocks it stops every
1882
+ # other in-flight request. Measured on 3.13.94 with time.sleep(10) in a
1883
+ # route, a trivial route took 9.004s instead of milliseconds.
1884
+ #
1885
+ # Threading the sync path fixes both at once, and is what Starlette and
1886
+ # FastAPI already do. It is also Python's answer to the same problem PHP
1887
+ # solved by forking per request: PHP has no threads, Python does.
1888
+ #
1889
+ # The check happens BEFORE the call, deliberately. Calling first and then
1890
+ # asking whether the result is awaitable would already have run the sync
1891
+ # handler on the loop, which is the thing being avoided.
1892
+ #
1893
+ # A callable OBJECT with an async __call__ reports False from
1894
+ # iscoroutinefunction(), so its __call__ is checked too - otherwise such a
1895
+ # handler would be sent to a thread and its coroutine never awaited.
1896
+ _handler = route["handler"]
1897
+ _is_async = inspect.iscoroutinefunction(_handler) or (
1898
+ not inspect.isfunction(_handler)
1899
+ and not inspect.ismethod(_handler)
1900
+ and inspect.iscoroutinefunction(getattr(_handler, "__call__", None))
1901
+ )
1902
+
1903
+ if _is_async:
1904
+ result = await (_handler() if _pcount == 0 else _handler(*_args))
1905
+ elif _pcount == 0:
1906
+ result = await asyncio.to_thread(_handler)
1872
1907
  else:
1873
- result = await route["handler"](*_args)
1908
+ result = await asyncio.to_thread(_handler, *_args)
1909
+
1874
1910
  if isinstance(result, Response):
1875
1911
  response = result
1876
1912
  return response
@@ -2549,6 +2585,62 @@ async def handle(request: Request) -> Response:
2549
2585
 
2550
2586
  return ctx.response
2551
2587
 
2588
+ def asgi(root_dir: str = "src"):
2589
+ """Build the ASGI application, with routes discovered.
2590
+
2591
+ Point any ASGI server at this - uvicorn, hypercorn, granian, daphne - when
2592
+ you want that server's process model instead of ``tina4 serve``:
2593
+
2594
+ # asgi.py
2595
+ from tina4_python.core.server import asgi
2596
+ app = asgi()
2597
+
2598
+ $ uvicorn asgi:app --workers 4
2599
+
2600
+ THE DISCOVERY IS THE POINT. Pointing a server straight at ``app`` below
2601
+ starts a working ASGI application with NO ROUTES REGISTERED, because
2602
+ discovery happens inside ``run()`` and nothing else calls it. Measured: the
2603
+ bare app answers /hello with a 404 page, and the same app behind this
2604
+ function answers 200. That failure is quiet - the server boots, the health
2605
+ page renders, and every application route 404s - so the framework supplies
2606
+ the bootstrap rather than leaving each user to find ``_auto_discover``,
2607
+ which is private and has no business in a deployment file.
2608
+
2609
+ :param root_dir: Directory to discover routes from. Defaults to ``src``.
2610
+ :return: The ASGI 3 callable.
2611
+ """
2612
+ _auto_discover(root_dir)
2613
+ return app
2614
+
2615
+
2616
+ async def _send_payload_too_large(send, received: int, limit: int) -> None:
2617
+ """Answer 413 for a body over TINA4_MAX_UPLOAD_SIZE.
2618
+
2619
+ Written straight to the ASGI `send` rather than routed through the normal
2620
+ response path: the request never became a Request object, so there is no
2621
+ route, no middleware and no session to run it through.
2622
+ """
2623
+ import json
2624
+
2625
+ payload = json.dumps(
2626
+ {
2627
+ "error": f"Request body ({received} bytes) exceeds "
2628
+ f"TINA4_MAX_UPLOAD_SIZE ({limit} bytes)"
2629
+ }
2630
+ ).encode("utf-8")
2631
+ await send(
2632
+ {
2633
+ "type": "http.response.start",
2634
+ "status": 413,
2635
+ "headers": [
2636
+ [b"content-type", b"application/json"],
2637
+ [b"content-length", str(len(payload)).encode()],
2638
+ ],
2639
+ }
2640
+ )
2641
+ await send({"type": "http.response.body", "body": payload, "more_body": False})
2642
+
2643
+
2552
2644
  async def app(scope: dict, receive, send):
2553
2645
  """ASGI entry point — compatible with uvicorn, hypercorn, granian."""
2554
2646
  if scope["type"] == "lifespan":
@@ -2584,16 +2676,74 @@ async def app(scope: dict, receive, send):
2584
2676
  if scope["type"] != "http":
2585
2677
  return
2586
2678
 
2587
- # Read full body
2588
- body = b""
2679
+ # Read the body, bounded, in one allocation.
2680
+ #
2681
+ # Two defects lived in the three lines this replaces.
2682
+ #
2683
+ # `body += chunk` on immutable bytes reallocates and copies the WHOLE
2684
+ # buffer per chunk, so an N-byte body costs O(N^2). Measured with uvicorn
2685
+ # feeding ~16KB chunks: a 40MB upload took the server from 51MB RSS to
2686
+ # 1213MB. A list plus one join is linear.
2687
+ #
2688
+ # And the size limit was enforced by Request.from_scope AFTER this loop
2689
+ # finished, so a body 4x over the limit was accumulated in full and only
2690
+ # then refused - the limit measured the damage instead of preventing it.
2691
+ # Checking per chunk stops at the limit. (Same defect, same fix, as the
2692
+ # Node request reader.)
2693
+ # Refuse on the DECLARED length before reading a byte. A client that
2694
+ # announces 32MB against a 1MB limit gets its answer immediately instead of
2695
+ # uploading 32MB first; and a client that announces a large body and then
2696
+ # sends almost nothing would otherwise hold the connection while the server
2697
+ # waits for the rest. This is what a reverse proxy does in front of you.
2698
+ declared = 0
2699
+ for _name, _value in scope.get("headers", []):
2700
+ if _name.lower() == b"content-length":
2701
+ try:
2702
+ declared = int(_value)
2703
+ except (TypeError, ValueError):
2704
+ declared = 0
2705
+ break
2706
+ if declared > TINA4_MAX_UPLOAD_SIZE:
2707
+ await _send_payload_too_large(send, declared, TINA4_MAX_UPLOAD_SIZE)
2708
+ return
2709
+
2710
+ chunks = []
2711
+ received = 0
2712
+ too_large = False
2589
2713
  while True:
2590
2714
  msg = await receive()
2591
- body += msg.get("body", b"")
2715
+ chunk = msg.get("body", b"")
2716
+ if chunk and not too_large:
2717
+ received += len(chunk)
2718
+ if received > TINA4_MAX_UPLOAD_SIZE:
2719
+ # Stop accumulating, but keep draining: an ASGI server expects
2720
+ # the request stream to be consumed, and abandoning it mid-body
2721
+ # can wedge the connection.
2722
+ too_large = True
2723
+ chunks = []
2724
+ else:
2725
+ chunks.append(chunk)
2592
2726
  if not msg.get("more_body", False):
2593
2727
  break
2594
2728
 
2729
+ if too_large:
2730
+ # 413, not 500. PayloadTooLarge was raised and caught by nobody, so an
2731
+ # oversized upload answered "Internal Server Error" - which tells the
2732
+ # caller to retry the request that will fail again.
2733
+ await _send_payload_too_large(send, received, TINA4_MAX_UPLOAD_SIZE)
2734
+ return
2735
+
2736
+ body = b"".join(chunks)
2737
+
2595
2738
  # Build request and dispatch
2596
- request = Request.from_scope(scope, body)
2739
+ try:
2740
+ request = Request.from_scope(scope, body)
2741
+ except PayloadTooLarge:
2742
+ # Still reachable: from_scope also refuses on a DECLARED content-length
2743
+ # over the limit, which the loop above never sees when the client lies
2744
+ # about the length or sends nothing.
2745
+ await _send_payload_too_large(send, received, TINA4_MAX_UPLOAD_SIZE)
2746
+ return
2597
2747
  response = await handle(request)
2598
2748
 
2599
2749
  # Streaming responses bypass ETag/compression — send immediately
@@ -2960,16 +3110,53 @@ def resolve_config(cli_host: str | None = None, cli_port: int | None = None) ->
2960
3110
  else:
2961
3111
  host = os.environ.get("TINA4_HOST") or os.environ.get("HOST", default_host)
2962
3112
 
2963
- # Port: CLI flag > PORT env > default
3113
+ # Port: CLI flag > TINA4_PORT > PORT (deprecated) > default.
3114
+ #
3115
+ # This used to read PORT and nothing else, so TINA4_PORT - the name the CLI
3116
+ # documents and prefers, and the one every .env sets - was IGNORED on the
3117
+ # one path that binds the socket. An operator setting it saw the server
3118
+ # come up on the default port with no complaint. It cost two benchmark runs
3119
+ # in one afternoon before anyone read this function.
3120
+ #
3121
+ # Bare PORT is deprecated: it is a name anything can set, and a stray
3122
+ # OS-level PORT on a shared runner should never outrank the framework's own
3123
+ # variable. It is still honoured so no deployment breaks, and warns so the
3124
+ # migration actually happens. Removal is 3.14.
2964
3125
  if cli_port is not None:
2965
3126
  port = cli_port
2966
3127
  else:
2967
- env_port = os.environ.get("PORT")
2968
- port = int(env_port) if env_port and env_port.isdigit() else default_port
3128
+ tina4_port = os.environ.get("TINA4_PORT")
3129
+ legacy_port = os.environ.get("PORT")
3130
+ if tina4_port and tina4_port.isdigit():
3131
+ port = int(tina4_port)
3132
+ elif legacy_port and legacy_port.isdigit():
3133
+ port = int(legacy_port)
3134
+ _warn_deprecated_port(port)
3135
+ else:
3136
+ port = default_port
2969
3137
 
2970
3138
  return host, port
2971
3139
 
2972
3140
 
3141
+ _PORT_DEPRECATION_WARNED = False
3142
+
3143
+
3144
+ def _warn_deprecated_port(port: int) -> None:
3145
+ """Warn ONCE that bare PORT was used instead of TINA4_PORT.
3146
+
3147
+ Once, because resolve_config can be called more than once per process and
3148
+ a warning repeated on every call is a warning people filter out.
3149
+ """
3150
+ global _PORT_DEPRECATION_WARNED
3151
+ if _PORT_DEPRECATION_WARNED:
3152
+ return
3153
+ _PORT_DEPRECATION_WARNED = True
3154
+ Log.warning(
3155
+ f"PORT is deprecated and will be removed in 3.14 - use TINA4_PORT "
3156
+ f"instead (binding port {port} from PORT)"
3157
+ )
3158
+
3159
+
2973
3160
  def banner_surface_lines(
2974
3161
  port: int, *, swagger_enabled: bool, dev_admin_enabled: bool
2975
3162
  ) -> tuple[str, str]:
@@ -119,18 +119,17 @@ class AutoCrud:
119
119
  records = _cls.all(limit=limit, offset=offset)
120
120
  total = _cls.count()
121
121
  total_pages = max(1, -(-total // limit)) if limit else 1
122
- data = [r.to_dict() for r in records]
122
+ record_dicts = [record.to_dict() for record in records]
123
+ # The canonical ADR-0043 envelope: EXACTLY seven snake_case keys, no
124
+ # duplicate or camelCase spellings, matching DatabaseResult.to_paginate().
123
125
  return response({
124
- "records": data, # standard name
125
- "data": data, # backwards compat
126
- "count": total, # standard name
127
- "total": total, # backwards compat
128
- "limit": limit,
129
- "offset": offset,
130
- "page": page,
131
- "per_page": limit, # backwards compat
132
- "totalPages": total_pages, # camelCase standard
133
- "total_pages": total_pages, # backwards compat
126
+ "records": record_dicts, # the page rows, verbatim (never re-sliced)
127
+ "total": total, # the true COUNT for the filter, NOT rows returned
128
+ "page": page, # floor(offset / limit) + 1
129
+ "per_page": limit, # the query's limit
130
+ "total_pages": total_pages, # ceil(total / per_page)
131
+ "limit": limit, # the SQL limit actually applied
132
+ "offset": offset, # the SQL offset actually applied
134
133
  })
135
134
 
136
135
  list_handler.__name__ = f"autocrud_list_{table}"
@@ -263,71 +263,66 @@ class DatabaseResult:
263
263
  rows = [",".join(escape(row.get(c)) for c in columns) for row in self.records]
264
264
  return "\n".join([header] + rows)
265
265
 
266
- def to_paginate(self, page: int = None, per_page: int = None) -> dict:
267
- """Paginate this result. Two modes, and the default one is new.
268
-
269
- NO ARGUMENTS: describe the page this result already IS, derived from
270
- the query that produced it. This is the mode that was broken.
266
+ def to_paginate(self, *args, **kwargs) -> dict:
267
+ """Describe the page this result already IS, as the canonical envelope.
268
+
269
+ Takes NO arguments. Every field is derived from the query that
270
+ produced this result (ADR-0043):
271
+
272
+ per_page = the query's limit
273
+ page = floor(offset / limit) + 1
274
+ total = the true total for the filter, the COUNT probe that
275
+ populates .count, NEVER the number of rows returned
276
+ total_pages = ceil(total / per_page)
277
+ records = the rows the query returned, VERBATIM, never re-sliced
278
+ limit = the SQL limit actually applied
279
+ offset = the SQL offset actually applied
280
+
281
+ The envelope is EXACTLY seven snake_case keys (records, total, page,
282
+ per_page, total_pages, limit, offset), identical in all four Tina4
283
+ frameworks. A JSON key is data and does not change spelling by host
284
+ language, so there is no camelCase and no duplicate spelling: the old
285
+ ``data``, ``count`` and ``totalPages`` aliases are gone.
286
+
287
+ Passing ANY argument RAISES. The method used to accept (page, per_page)
288
+ and slice the rows in memory (GitHub issue #106), but a DatabaseResult
289
+ holds no connection, so an argument can only re-slice the rows already
290
+ in memory and then report total_pages for pages it can never reach --
291
+ the measured Ruby defect where slicing offset 40 into a 20-row page
292
+ returned NOTHING while still naming a page and a total. To read a
293
+ different page, FETCH that page (limit + offset) and call this with no
294
+ arguments.
271
295
 
272
296
  MEASURED 2026-08-05 on a real 250-row table read with limit=20
273
- offset=40 (page 3 of 13): this reported page 1. It defaulted the page
274
- rather than deriving it from the offset, so every offset fetch was
275
- mislabelled as the first page while carrying a correct total and a
276
- correct total_pages - an envelope that looked authoritative and named
277
- the wrong page.
278
-
279
- WITH page/per_page: slice this result in memory, which is what GitHub
280
- issue #106 asked for and what tests/test_issue_106.py pins. Valid only
281
- when the result holds the WHOLE set. Slicing a result that is already
282
- one page of a larger query is refused: the slice is taken from row 0 of
283
- what you hold, while the rows themselves start at ``offset``, so the
284
- answer is silently wrong. That is not hypothetical - it is exactly the
285
- measured Ruby defect, where slicing offset 40 into a 20-row page
286
- returned NOTHING and still reported a page number and a total.
287
-
288
- Python and PHP populate ``count`` from a separate COUNT probe, so
289
- ``total`` here is the TRUE total for the filter and not the capped read
290
- - which is what makes the envelope honest. Ruby and Node still populate
291
- it with rows returned; see feature 18.
297
+ offset=40 (page 3 of 13): the envelope shipped the same integer twice
298
+ under two spellings (totalPages and total_pages, count and total, data
299
+ and records) in every API response, so every consumer had to guess
300
+ which spelling was canonical. That, and the silent in-memory slice, are
301
+ what this fixes.
292
302
  """
293
- if (page is not None or per_page is not None) and len(self.records) < self.count:
294
- raise ValueError(
295
- f"to_paginate(page=..., per_page=...) slices the rows this result holds, "
296
- f"but this result holds only {len(self.records)} of {self.count} rows - "
297
- "it is a PARTIAL result, so any page past the rows it holds comes back "
298
- "empty while total_pages claims it exists. MEASURED on 100,000 rows read "
299
- "under the default cap of 100: pages 1-5 of 20 were right and pages 6 "
300
- "onward returned NOTHING, with the envelope still reporting 5,000 pages. "
301
- "Fetch the page you want instead: fetch(sql, limit=per_page, "
302
- "offset=(page - 1) * per_page), then call to_paginate() with no arguments."
303
+ if args or kwargs:
304
+ raise TypeError(
305
+ "to_paginate() takes no arguments -- it describes the page the "
306
+ "query already returned, derived from the limit and offset that "
307
+ "ran. It used to accept (page, per_page) and slice in memory, but "
308
+ "a DatabaseResult holds no connection, so an argument can only "
309
+ "re-slice the rows already in memory and then report total_pages "
310
+ "for pages it can never reach. To read a different page, FETCH it: "
311
+ "fetch(sql, params, limit=per_page, offset=(page - 1) * per_page), "
312
+ "then call to_paginate() with no arguments. (ADR-0043)"
303
313
  )
304
314
 
305
- if page is None and per_page is None:
306
- per_page = self.limit if self.limit and self.limit > 0 else len(self.records)
307
- page = (self.offset // per_page) + 1 if per_page > 0 else 1
308
- total_pages = max(1, -(-self.count // per_page)) if per_page > 0 else 1
309
- return self._paginate_envelope(self.records, page, per_page, self.offset, total_pages)
310
-
311
- page = page or 1
312
- per_page = per_page or (self.limit if self.limit and self.limit > 0 else 20)
313
- total_pages = max(1, -(-self.count // per_page))
314
- offset = (page - 1) * per_page
315
- data = self.records[offset:offset + per_page]
316
- return self._paginate_envelope(data, page, per_page, offset, total_pages)
317
-
318
- def _paginate_envelope(self, data, page, per_page, offset, total_pages) -> dict:
319
- """The one envelope shape, so both modes cannot drift apart."""
315
+ per_page = self.limit if self.limit and self.limit > 0 else len(self.records)
316
+ page = (self.offset // per_page) + 1 if per_page > 0 else 1
317
+ total_pages = max(1, -(-self.count // per_page)) if per_page > 0 else 1
320
318
  return {
321
- "records": data, # standard name
322
- "data": data, # backwards compat (PHP/Ruby/Node)
323
- "count": self.count, # standard name
324
- "total": self.count, # backwards compat
325
- "limit": per_page, # standard name
326
- "offset": offset, # standard name
327
- "page": page,
328
- "per_page": per_page, # backwards compat
329
- "totalPages": total_pages, # camelCase standard
330
- "total_pages": total_pages, # backwards compat
319
+ "records": self.records, # the rows returned, verbatim (never re-sliced)
320
+ "total": self.count, # the true COUNT for the filter, not rows returned
321
+ "page": page, # floor(offset / limit) + 1
322
+ "per_page": per_page, # the query's limit
323
+ "total_pages": total_pages, # ceil(total / per_page)
324
+ "limit": per_page, # the SQL limit actually applied
325
+ "offset": self.offset, # the SQL offset actually applied
331
326
  }
332
327
 
333
328
  def column_info(self) -> list[dict]: