tina4-python 3.13.69__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 (168) hide show
  1. {tina4_python-3.13.69 → tina4_python-3.13.70}/PKG-INFO +1 -1
  2. {tina4_python-3.13.69 → tina4_python-3.13.70}/pyproject.toml +1 -1
  3. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/firebird.py +35 -2
  4. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/dev_admin/__init__.py +7 -49
  5. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/dev_admin/metrics.py +1 -1
  6. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/mcp/tools.py +27 -14
  7. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/orm/model.py +51 -2
  8. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/seeder/__init__.py +68 -1
  9. {tina4_python-3.13.69 → tina4_python-3.13.70}/.gitignore +0 -0
  10. {tina4_python-3.13.69 → tina4_python-3.13.70}/README.md +0 -0
  11. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/CLAUDE.md +0 -0
  12. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/__init__.py +0 -0
  15. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/ai/__init__.py +0 -0
  16. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/api/__init__.py +0 -0
  17. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/auth/__init__.py +0 -0
  18. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/cache/__init__.py +0 -0
  19. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/cli/__init__.py +0 -0
  20. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/container/__init__.py +0 -0
  21. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/context/__init__.py +0 -0
  22. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/context/chunker.py +0 -0
  23. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/__init__.py +0 -0
  24. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/cache.py +0 -0
  25. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/constants.py +0 -0
  26. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/events.py +0 -0
  27. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/middleware.py +0 -0
  28. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/rate_limiter.py +0 -0
  29. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/request.py +0 -0
  30. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/response.py +0 -0
  31. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/router.py +0 -0
  32. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/core/server.py +0 -0
  33. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/crud/__init__.py +0 -0
  34. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/__init__.py +0 -0
  35. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/adapter.py +0 -0
  36. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/connection.py +0 -0
  37. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/mongodb.py +0 -0
  38. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/mssql.py +0 -0
  39. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/mysql.py +0 -0
  40. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/odbc.py +0 -0
  41. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/postgres.py +0 -0
  42. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/database/sqlite.py +0 -0
  43. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/debug/__init__.py +0 -0
  44. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/debug/error_overlay.py +0 -0
  45. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/dev_admin/plan.py +0 -0
  46. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/dev_admin/project_index.py +0 -0
  47. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/docs.py +0 -0
  48. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/docstore/__init__.py +0 -0
  49. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/dotenv/__init__.py +0 -0
  50. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/env.py +0 -0
  51. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/frond/FROND.md +0 -0
  52. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/frond/__init__.py +0 -0
  53. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/frond/engine.py +0 -0
  54. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/auth/meta.json +0 -0
  55. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  56. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/database/meta.json +0 -0
  57. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  58. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/error-overlay/meta.json +0 -0
  59. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  60. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/orm/meta.json +0 -0
  61. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  62. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  63. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/queue/meta.json +0 -0
  64. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  65. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/rest-api/meta.json +0 -0
  66. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  67. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/templates/meta.json +0 -0
  68. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  69. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  70. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/graphql/__init__.py +0 -0
  71. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/i18n/__init__.py +0 -0
  72. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/mcp/__init__.py +0 -0
  73. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/mcp/protocol.py +0 -0
  74. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/messenger/__init__.py +0 -0
  75. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/migration/__init__.py +0 -0
  76. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/migration/runner.py +0 -0
  77. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/orm/__init__.py +0 -0
  78. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/orm/fields.py +0 -0
  79. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/__feedback/widget.js +0 -0
  80. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/css/tina4.css +0 -0
  81. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/css/tina4.min.css +0 -0
  82. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/favicon.ico +0 -0
  83. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/images/logo.svg +0 -0
  84. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  85. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/js/frond.js +0 -0
  86. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/js/frond.min.js +0 -0
  87. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  88. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  89. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/js/tina4.min.js +0 -0
  90. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/js/tina4js.min.js +0 -0
  91. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/swagger/index.html +0 -0
  92. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  93. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/query_builder/__init__.py +0 -0
  94. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue/__init__.py +0 -0
  95. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue/job.py +0 -0
  96. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue/kafka_backend.py +0 -0
  97. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue/lite_backend.py +0 -0
  98. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue/mongo_backend.py +0 -0
  99. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue_backends/__init__.py +0 -0
  101. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue_backends/kafka_backend.py +0 -0
  102. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue_backends/mongo_backend.py +0 -0
  103. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  104. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/realtime/__init__.py +0 -0
  105. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/realtime/models/Attachment.py +0 -0
  106. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/realtime/models/Channel.py +0 -0
  107. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/realtime/models/ChannelMember.py +0 -0
  108. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/realtime/models/Message.py +0 -0
  109. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/realtime/models/Workspace.py +0 -0
  110. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/realtime/models/__init__.py +0 -0
  111. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/realtime/storage.py +0 -0
  112. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/__init__.py +0 -0
  113. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  114. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_badges.scss +0 -0
  115. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  116. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_cards.scss +0 -0
  117. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_forms.scss +0 -0
  118. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_grid.scss +0 -0
  119. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_modals.scss +0 -0
  120. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_nav.scss +0 -0
  121. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_reset.scss +0 -0
  122. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_tables.scss +0 -0
  123. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_typography.scss +0 -0
  124. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  125. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/_variables.scss +0 -0
  126. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/base.scss +0 -0
  127. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/colors.scss +0 -0
  128. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/scss/tina4css/tina4.scss +0 -0
  129. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/service/__init__.py +0 -0
  130. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/session/__init__.py +0 -0
  131. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/session_handlers/__init__.py +0 -0
  132. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  133. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/session_handlers/redis_handler.py +0 -0
  134. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/session_handlers/valkey_handler.py +0 -0
  135. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/swagger/__init__.py +0 -0
  136. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/components/crud.twig +0 -0
  137. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  138. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  139. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/docker/python/Dockerfile +0 -0
  140. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  141. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/errors/302.twig +0 -0
  142. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/errors/401.twig +0 -0
  143. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/errors/403.twig +0 -0
  144. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/errors/404.twig +0 -0
  145. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/errors/500.twig +0 -0
  146. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/errors/502.twig +0 -0
  147. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/errors/503.twig +0 -0
  148. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/errors/base.twig +0 -0
  149. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/frontend/README.md +0 -0
  150. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/templates/readme.md +0 -0
  151. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/test/__init__.py +0 -0
  152. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/test_client/__init__.py +0 -0
  153. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  156. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  157. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  158. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  159. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  160. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  161. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/validator/__init__.py +0 -0
  166. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/websocket/__init__.py +0 -0
  167. {tina4_python-3.13.69 → tina4_python-3.13.70}/tina4_python/websocket/backplane.py +0 -0
  168. {tina4_python-3.13.69 → 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.69
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.69"
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,7 @@ Requires: pip install firebird-driver (or pip install fdb for legacy)
8
8
  """
9
9
  import os
10
10
  import re
11
- from urllib.parse import urlparse, unquote
11
+ from urllib.parse import urlparse, unquote, parse_qs
12
12
  from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult, SQLTranslator
13
13
 
14
14
 
@@ -68,6 +68,35 @@ def _normalize_firebird_db_identifier(raw_path: str) -> str:
68
68
  return decoded if decoded.startswith("/") else "/" + decoded
69
69
 
70
70
 
71
+ def _resolve_firebird_charset(connection_string: str, kwarg_charset: str | None = None) -> str:
72
+ """Resolve the Firebird connection charset (php #160).
73
+
74
+ The adapter used to hardcode ``UTF8`` with no override, which
75
+ double-encodes UTF-8 bytes stored under a legacy ``NONE`` database. This
76
+ resolves the charset from, in precedence order:
77
+
78
+ 1. the connection URL query — ``firebird://host:port/path?charset=NONE``
79
+ 2. an explicit ``charset=`` keyword passed to :meth:`connect`
80
+ 3. the ``TINA4_DATABASE_CHARSET`` environment variable
81
+ 4. the ``UTF8`` default (unchanged — non-breaking for existing connections)
82
+
83
+ Pure config resolution over its inputs (URL string, kwarg, env) — no
84
+ connection is opened, so it is unit-testable without a live server.
85
+ """
86
+ url_charset = None
87
+ query = urlparse(connection_string).query
88
+ if query:
89
+ values = parse_qs(query).get("charset")
90
+ if values:
91
+ url_charset = values[0]
92
+ return (
93
+ url_charset
94
+ or kwarg_charset
95
+ or os.environ.get("TINA4_DATABASE_CHARSET")
96
+ or "UTF8"
97
+ )
98
+
99
+
71
100
  # Try modern firebird-driver first, fall back to legacy fdb
72
101
  _driver = None
73
102
  _driver_name = None
@@ -142,7 +171,11 @@ class FirebirdAdapter(DatabaseAdapter):
142
171
 
143
172
  user = parsed.username or username or "SYSDBA"
144
173
  password = parsed.password or password or "masterkey"
145
- charset = kwargs.pop("charset", "UTF8")
174
+ # php #160: honour ?charset= in the URL and TINA4_DATABASE_CHARSET so a
175
+ # legacy NONE database isn't force-connected as UTF8 (double-encoding).
176
+ # Pop the kwarg first so it isn't also forwarded into the driver connect
177
+ # via **extra below (which would raise a duplicate-keyword error).
178
+ charset = _resolve_firebird_charset(connection_string, kwargs.pop("charset", None))
146
179
 
147
180
  # Cache for transparent reconnect — never logged, lives only in
148
181
  # adapter memory alongside the connection it owns.
@@ -953,7 +953,7 @@ async def _api_seed_table(request, response):
953
953
  """Seed fake data into a database table from the admin UI."""
954
954
  try:
955
955
  from tina4_python.database import Database
956
- from tina4_python.seeder import FakeData, seed_table
956
+ from tina4_python.seeder import FakeData, seed_table, auto_field_map
957
957
 
958
958
  body = request.body if hasattr(request, "body") and request.body else {}
959
959
  table = body.get("table", "")
@@ -985,54 +985,12 @@ async def _api_seed_table(request, response):
985
985
 
986
986
  fake = FakeData(seed=seed_val)
987
987
 
988
- def _gen_for_column(col):
989
- """Pick a FakeData generator for one column from its name + type."""
990
- name = col.get("name", col.get("column_name", ""))
991
- col_type = col.get("type", col.get("data_type", "")).upper()
992
- name_lower = name.lower()
993
- if "email" in name_lower:
994
- return fake.email
995
- if "name" in name_lower and "user" in name_lower:
996
- return fake.name
997
- if "first" in name_lower and "name" in name_lower:
998
- return fake.first_name
999
- if "last" in name_lower and "name" in name_lower:
1000
- return fake.last_name
1001
- if "name" in name_lower:
1002
- return fake.name
1003
- if "phone" in name_lower or "tel" in name_lower:
1004
- return fake.phone
1005
- if "url" in name_lower or "link" in name_lower:
1006
- return fake.url
1007
- if "address" in name_lower:
1008
- return fake.address
1009
- if "date" in name_lower or "time" in name_lower or "created" in name_lower:
1010
- return fake.datetime_iso
1011
- if "desc" in name_lower or "body" in name_lower or "content" in name_lower:
1012
- return fake.paragraph
1013
- if "title" in name_lower or "subject" in name_lower:
1014
- return fake.sentence
1015
- if "active" in name_lower or "enabled" in name_lower or "done" in name_lower:
1016
- return fake.boolean
1017
- if "INT" in col_type or "SERIAL" in col_type:
1018
- return lambda: fake.integer(1, 10000)
1019
- if any(t in col_type for t in ("REAL", "FLOAT", "DOUBLE", "NUMERIC", "DECIMAL")):
1020
- return lambda: fake.decimal(0, 1000)
1021
- if "BOOL" in col_type:
1022
- return fake.boolean
1023
- return fake.sentence
1024
-
1025
- # Build a field_map skipping auto-increment / id PKs, then delegate to
1026
- # seed_table so this endpoint shares the exact same visible-but-resilient
1027
- # per-row error handling (P1/P4b) — no unhandled row failure crashes it.
1028
- field_map = {}
1029
- for col in columns:
1030
- name = col.get("name", col.get("column_name", ""))
1031
- col_type = col.get("type", col.get("data_type", "")).upper()
1032
- is_pk = col.get("primary_key", col.get("pk", False))
1033
- if is_pk and ("AUTO" in col_type or "SERIAL" in col_type or name.lower() == "id"):
1034
- continue
1035
- field_map[name] = _gen_for_column(col)
988
+ # Build a field_map (column -> generator, skipping auto-increment / id
989
+ # PKs) via the shared seeder helper, then delegate to seed_table so this
990
+ # endpoint shares the exact same visible-but-resilient per-row error
991
+ # handling (P1/P4b) — no unhandled row failure crashes it. The MCP
992
+ # seed_table dev tool uses the identical helper for parity.
993
+ field_map = auto_field_map(db, table, fake)
1036
994
 
1037
995
  try:
1038
996
  summary = seed_table(db, table, count, field_map=field_map,
@@ -129,7 +129,7 @@ def quick_metrics(root: str = "src") -> dict:
129
129
  orm_count = 0
130
130
  try:
131
131
  from tina4_python.core.router import Router
132
- route_count = len(Router._routes) if hasattr(Router, "_routes") else 0
132
+ route_count = len(Router.get_routes())
133
133
  except Exception:
134
134
  pass
135
135
  try:
@@ -307,7 +307,11 @@ def register_dev_tools(server):
307
307
  def swagger_spec() -> dict:
308
308
  """Return the full OpenAPI 3.0.3 JSON specification."""
309
309
  from tina4_python.swagger import Swagger
310
- return Swagger.generate()
310
+ from tina4_python.core.router import Router
311
+ # Swagger.generate is an INSTANCE method that takes the route table;
312
+ # build the spec from the live routes (same shape AutoCrud/the dev
313
+ # /swagger endpoint use). Canonical call: Swagger().generate(Router.get_routes()).
314
+ return Swagger().generate(Router.get_routes())
311
315
 
312
316
  # ── Template Tools ──────────────────────────────────────────
313
317
 
@@ -481,15 +485,15 @@ def register_dev_tools(server):
481
485
 
482
486
  # ── Migration Tools ─────────────────────────────────────────
483
487
 
484
- def migration_status() -> list:
488
+ def migration_status() -> dict:
485
489
  """List pending and completed migrations."""
486
490
  from tina4_python.orm.model import ORM
487
- from tina4_python.migration.runner import MigrationRunner
488
- db = ORM._database
491
+ from tina4_python.migration import status
492
+ db = ORM._get_db() if hasattr(ORM, "_get_db") else None
489
493
  if db is None:
490
494
  return {"error": "No database connection"}
491
- runner = MigrationRunner(db)
492
- return runner.status() if hasattr(runner, "status") else {"info": "Migration status not available"}
495
+ # status(db) == Migration(db).status(); returns {"completed": [...], "pending": [...]}.
496
+ return status(db)
493
497
 
494
498
  def migration_create(description: str) -> dict:
495
499
  """Create a new migration file. Refuses to create a duplicate
@@ -541,12 +545,14 @@ def register_dev_tools(server):
541
545
  def migration_run() -> dict:
542
546
  """Run all pending migrations."""
543
547
  from tina4_python.orm.model import ORM
544
- from tina4_python.migration import Migration
545
- db = ORM._database
548
+ from tina4_python.migration import migrate
549
+ db = ORM._get_db() if hasattr(ORM, "_get_db") else None
546
550
  if db is None:
547
551
  return {"error": "No database connection"}
548
- result = Migration(db).migrate()
549
- return {"result": str(result)}
552
+ # migrate(db) == Migration(db).migrate(); returns the list of applied
553
+ # migration filenames (empty when nothing was pending).
554
+ applied = migrate(db)
555
+ return {"applied": applied, "count": len(applied)}
550
556
 
551
557
  # ── Queue Tools ─────────────────────────────────────────────
552
558
 
@@ -638,13 +644,20 @@ def register_dev_tools(server):
638
644
  def seed_table(table: str, count: int = 10) -> dict:
639
645
  """Seed a table with fake data."""
640
646
  try:
641
- from tina4_python.seeder import seed_table as _seed
647
+ from tina4_python.seeder import seed_table as _seed, auto_field_map
642
648
  from tina4_python.orm.model import ORM
643
- db = ORM._database
649
+ db = ORM._get_db() if hasattr(ORM, "_get_db") else None
644
650
  if db is None:
645
651
  return {"error": "No database connection"}
646
- inserted = _seed(db, table, count)
647
- return {"table": table, "inserted": inserted}
652
+ # seed_table needs an explicit column->generator field_map (no map =
653
+ # no rows, by design). auto_field_map introspects the table columns
654
+ # and builds one — the same helper the dev-admin seeding endpoint
655
+ # uses — so the tool actually inserts fake rows.
656
+ field_map = auto_field_map(db, table)
657
+ if not field_map:
658
+ return {"error": f"Table '{table}' not found or has no seedable columns"}
659
+ summary = _seed(db, table, count, field_map=field_map)
660
+ return {"table": table, "inserted": summary.seeded, "failed": summary.failed}
648
661
  except Exception as e:
649
662
  return {"error": str(e)}
650
663
 
@@ -183,6 +183,15 @@ class ORM(metaclass=ORMMeta):
183
183
  # Initialize relationship cache
184
184
  self._rel_cache = {}
185
185
 
186
+ # #165: track which fields the caller EXPLICITLY assigned (via
187
+ # constructor data/kwargs, _populate, or ``model.field = x``). save()
188
+ # uses this to OMIT an unset column from an INSERT — so a NOT NULL
189
+ # DEFAULT column gets its DB default instead of an explicit NULL —
190
+ # while still writing NULL for a field the caller set to None. The
191
+ # defaults seeded below go through object.__setattr__ so they are NOT
192
+ # counted as caller assignments.
193
+ self._assigned_fields = set()
194
+
186
195
  # Set defaults from field definitions.
187
196
  # v3.13.11 (issue #50): callable defaults are evaluated *per
188
197
  # instance* so per-row timestamps (e.g. ``default=lambda:
@@ -190,7 +199,7 @@ class ORM(metaclass=ORMMeta):
190
199
  # object reached the driver verbatim and blew up with
191
200
  # ``can't adapt type 'function'``.
192
201
  for name, field in self._fields.items():
193
- setattr(self, name, field._resolve_default())
202
+ object.__setattr__(self, name, field._resolve_default())
194
203
 
195
204
  # Accept JSON string or dict
196
205
  if isinstance(data, str):
@@ -213,6 +222,22 @@ class ORM(metaclass=ORMMeta):
213
222
  if kwargs:
214
223
  self._populate(kwargs)
215
224
 
225
+ def __setattr__(self, name, value):
226
+ """Set an attribute, recording explicit assignment to declared fields.
227
+
228
+ #165: ``save()`` reads ``_assigned_fields`` to tell a column the caller
229
+ actually assigned (write it — an explicit ``None`` becomes ``NULL``)
230
+ from one left at its default (OMIT from the INSERT so a
231
+ ``NOT NULL DEFAULT`` column gets its DB default rather than an explicit
232
+ ``NULL``). ``__init__`` seeds field defaults with ``object.__setattr__``,
233
+ so only genuine caller assignments are tracked here. Non-field attrs
234
+ (``last_error``, ``_rel_cache``, relationships, …) are never recorded.
235
+ """
236
+ assigned = self.__dict__.get("_assigned_fields")
237
+ if assigned is not None and name in type(self)._fields:
238
+ assigned.add(name)
239
+ object.__setattr__(self, name, value)
240
+
216
241
  def _populate(self, data: dict):
217
242
  """Set field values from a dict.
218
243
 
@@ -377,6 +402,11 @@ class ORM(metaclass=ORMMeta):
377
402
  pk_db_col = self.field_mapping.get(pk, self._fields[pk].column)
378
403
 
379
404
  data = {}
405
+ # #165: db columns to OMIT from an INSERT — those the caller never
406
+ # assigned AND whose value is None. Omitting them lets the DB DEFAULT
407
+ # apply (e.g. NOT NULL DEFAULT '') instead of emitting an explicit NULL
408
+ # that violates the constraint. Unused on the UPDATE path.
409
+ insert_omit = set()
380
410
  for name, field in self._fields.items():
381
411
  if field.auto_increment and pk_value is None:
382
412
  continue # Skip auto-increment on insert
@@ -393,6 +423,13 @@ class ORM(metaclass=ORMMeta):
393
423
  # Serialize to the column's storage form: identity for most
394
424
  # fields, JSON string for JSONField (see Field.to_db).
395
425
  data[db_col] = field.to_db(value)
426
+ # #165: a None value the caller never assigned is an UNSET
427
+ # column — omit it from the INSERT so the DB DEFAULT applies.
428
+ # A resolved ORM default (non-None) is still written; a value
429
+ # the caller explicitly set to None is still written as NULL
430
+ # (its field name is in _assigned_fields).
431
+ if value is None and name not in self._assigned_fields:
432
+ insert_omit.add(db_col)
396
433
 
397
434
  # v3.13.11 (issue #50): pick INSERT vs UPDATE on row existence
398
435
  # for non-auto-increment PKs. Auto-increment keeps the legacy
@@ -420,7 +457,19 @@ class ORM(metaclass=ORMMeta):
420
457
  if update_data:
421
458
  db.update(table, update_data, f"{pk_db_col} = ?", [pk_value])
422
459
  else:
423
- db.insert(table, data)
460
+ # #165: drop unset-None columns so the DB DEFAULT applies.
461
+ insert_data = {c: v for c, v in data.items() if c not in insert_omit}
462
+ if insert_data:
463
+ db.insert(table, insert_data)
464
+ else:
465
+ # Every insertable column was left unset — let the DB
466
+ # apply ALL its column defaults rather than emitting
467
+ # explicit NULLs. DEFAULT VALUES is valid on SQLite /
468
+ # PostgreSQL / MSSQL / Firebird; MySQL spells it () VALUES ().
469
+ if db.get_database_type() == "mysql":
470
+ db.execute(f"INSERT INTO {table} () VALUES ()")
471
+ else:
472
+ db.execute(f"INSERT INTO {table} DEFAULT VALUES")
424
473
  # Only adopt the engine-assigned ID for auto-increment PKs.
425
474
  # Natural-key PKs were already set by the caller; don't
426
475
  # overwrite them with the driver's last_id (which on PG
@@ -346,6 +346,72 @@ class SeedSummary(int):
346
346
  return f"SeedSummary(seeded={self.seeded}, failed={self.failed}, errors={self.errors!r})"
347
347
 
348
348
 
349
+ def _generator_for_column(fake: "FakeData", name: str, col_type: str):
350
+ """Pick a FakeData generator for one column from its name + type.
351
+
352
+ The shared heuristic behind :func:`auto_field_map`, so the dev-admin
353
+ seeding endpoint and the MCP ``seed_table`` dev tool auto-generate
354
+ identical column data. Falls back to a sentence for anything unrecognised.
355
+ """
356
+ name_lower = (name or "").lower()
357
+ col_type = (col_type or "").upper()
358
+ if "email" in name_lower:
359
+ return fake.email
360
+ if "name" in name_lower and "user" in name_lower:
361
+ return fake.name
362
+ if "first" in name_lower and "name" in name_lower:
363
+ return fake.first_name
364
+ if "last" in name_lower and "name" in name_lower:
365
+ return fake.last_name
366
+ if "name" in name_lower:
367
+ return fake.name
368
+ if "phone" in name_lower or "tel" in name_lower:
369
+ return fake.phone
370
+ if "url" in name_lower or "link" in name_lower:
371
+ return fake.url
372
+ if "address" in name_lower:
373
+ return fake.address
374
+ if "date" in name_lower or "time" in name_lower or "created" in name_lower:
375
+ return fake.datetime_iso
376
+ if "desc" in name_lower or "body" in name_lower or "content" in name_lower:
377
+ return fake.paragraph
378
+ if "title" in name_lower or "subject" in name_lower:
379
+ return fake.sentence
380
+ if "active" in name_lower or "enabled" in name_lower or "done" in name_lower:
381
+ return fake.boolean
382
+ if "INT" in col_type or "SERIAL" in col_type:
383
+ return lambda: fake.integer(1, 10000)
384
+ if any(t in col_type for t in ("REAL", "FLOAT", "DOUBLE", "NUMERIC", "DECIMAL")):
385
+ return lambda: fake.decimal(0, 1000)
386
+ if "BOOL" in col_type:
387
+ return fake.boolean
388
+ return fake.sentence
389
+
390
+
391
+ def auto_field_map(db, table: str, fake: "FakeData" = None) -> dict:
392
+ """Introspect ``table``'s columns and build a column->generator field_map
393
+ for :func:`seed_table`, skipping auto-increment / ``id`` primary keys.
394
+
395
+ This is the "seed a table I did not hand-write generators for" convenience
396
+ that both the dev-admin seeding endpoint and the MCP ``seed_table`` dev tool
397
+ need. ``seed_table`` itself stays explicit (no field_map = no rows); this
398
+ helper is how a caller opts into automatic generation. Returns an empty dict
399
+ when the table has no columns, so ``seed_table`` then seeds nothing rather
400
+ than crashing.
401
+ """
402
+ fake = fake or FakeData()
403
+ columns = db.get_columns(table) or []
404
+ field_map: dict[str, callable] = {}
405
+ for col in columns:
406
+ name = col.get("name", col.get("column_name", ""))
407
+ col_type = str(col.get("type", col.get("data_type", "")) or "").upper()
408
+ is_pk = col.get("primary_key", col.get("pk", False))
409
+ if is_pk and ("AUTO" in col_type or "SERIAL" in col_type or name.lower() == "id"):
410
+ continue
411
+ field_map[name] = _generator_for_column(fake, name, col_type)
412
+ return field_map
413
+
414
+
349
415
  def seed_table(db, table: str, count: int = 10,
350
416
  field_map: dict[str, callable] = None,
351
417
  overrides: dict = None, clear: bool = False,
@@ -766,4 +832,5 @@ def _generate_for_field(fake: FakeData, field_def: dict, col: str):
766
832
  return fake.word()
767
833
 
768
834
 
769
- __all__ = ["FakeData", "SeedSummary", "seed_table", "seed_orm", "seed_models"]
835
+ __all__ = ["FakeData", "SeedSummary", "seed_table", "seed_orm", "seed_models",
836
+ "auto_field_map"]
File without changes