tina4-python 3.13.16__tar.gz → 3.13.21__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 (157) hide show
  1. {tina4_python-3.13.16 → tina4_python-3.13.21}/PKG-INFO +1 -1
  2. {tina4_python-3.13.16 → tina4_python-3.13.21}/pyproject.toml +1 -1
  3. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/CLAUDE.md +32 -9
  4. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/__init__.py +2 -2
  5. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/auth/__init__.py +42 -20
  6. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/middleware.py +4 -4
  7. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/response.py +33 -1
  8. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/frond/engine.py +2 -2
  9. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/orm/__init__.py +2 -2
  10. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/orm/model.py +21 -11
  11. {tina4_python-3.13.16 → tina4_python-3.13.21}/.gitignore +0 -0
  12. {tina4_python-3.13.16 → tina4_python-3.13.21}/README.md +0 -0
  13. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/HtmlElement.py +0 -0
  14. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/Testing.py +0 -0
  15. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/ai/__init__.py +0 -0
  16. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/api/__init__.py +0 -0
  17. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/__init__.py +0 -0
  21. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/cache.py +0 -0
  22. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/constants.py +0 -0
  23. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/events.py +0 -0
  24. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/rate_limiter.py +0 -0
  25. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/request.py +0 -0
  26. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/router.py +0 -0
  27. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/core/server.py +0 -0
  28. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/crud/__init__.py +0 -0
  29. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/__init__.py +0 -0
  30. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/adapter.py +0 -0
  31. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/connection.py +0 -0
  32. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/firebird.py +0 -0
  33. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/mongodb.py +0 -0
  34. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/mssql.py +0 -0
  35. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/mysql.py +0 -0
  36. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/postgres.py +0 -0
  38. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/database/sqlite.py +0 -0
  39. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/debug/__init__.py +0 -0
  40. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/debug/error_overlay.py +0 -0
  41. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/dev_admin/__init__.py +0 -0
  42. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/dev_admin/metrics.py +0 -0
  43. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/dev_admin/plan.py +0 -0
  44. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/dev_admin/project_index.py +0 -0
  45. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/docs.py +0 -0
  46. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/dotenv/__init__.py +0 -0
  47. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/env.py +0 -0
  48. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/frond/FROND.md +0 -0
  49. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/frond/__init__.py +0 -0
  50. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/auth/meta.json +0 -0
  51. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  52. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/database/meta.json +0 -0
  53. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  54. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/error-overlay/meta.json +0 -0
  55. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  56. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/orm/meta.json +0 -0
  57. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  58. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  59. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/queue/meta.json +0 -0
  60. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  61. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/rest-api/meta.json +0 -0
  62. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  63. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/templates/meta.json +0 -0
  64. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  65. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  66. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/graphql/__init__.py +0 -0
  67. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/i18n/__init__.py +0 -0
  68. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/mcp/__init__.py +0 -0
  69. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/mcp/protocol.py +0 -0
  70. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/mcp/tools.py +0 -0
  71. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/orm/fields.py +0 -0
  75. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.16 → tina4_python-3.13.21}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.16 → tina4_python-3.13.21}/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.16
3
+ Version: 3.13.21
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.16"
3
+ version = "3.13.21"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -128,13 +128,13 @@ Rules:
128
128
  ```python
129
129
  # app.py
130
130
  from tina4_python.core import run
131
- from tina4_python.orm import orm_bind
131
+ from tina4_python.orm import bind_database
132
132
  from tina4_python.frond import Frond
133
133
  from tina4_python.database import Database
134
134
 
135
135
  # 1. Database & ORM
136
136
  db = Database("sqlite:///app.db")
137
- orm_bind(db)
137
+ bind_database(db)
138
138
 
139
139
  # 2. Custom Twig filters
140
140
  Frond.add_filter("money", lambda v: f"{float(v or 0):,.2f}")
@@ -328,7 +328,7 @@ tina4_python/ # Core framework package (v3.0.0)
328
328
  │ ├── connection.py # Database class (URL-based connection)
329
329
  │ ├── adapter.py # DatabaseAdapter, DatabaseResult, SQLTranslator
330
330
  │ ├── sqlite.py, postgres.py, mysql.py, mssql.py, firebird.py, odbc.py
331
- ├── orm/ # Active Record ORM (ORM, Field, orm_bind)
331
+ ├── orm/ # Active Record ORM (ORM, Field, bind_database)
332
332
  │ ├── model.py # ORM base class
333
333
  │ └── fields.py # IntegerField, StringField, etc.
334
334
  ├── frond/ # Template engine (Frond — Jinja2/Twig-compatible)
@@ -479,6 +479,9 @@ request.session # Session object — use .get(key) and .set(key, value)
479
479
 
480
480
  ```python
481
481
  return response({"data": []}) # JSON (auto-detected from dict/list)
482
+ return response(User.find(1)) # ORM model -> JSON object
483
+ return response(User.all()) # list of models -> JSON array
484
+ return response(db.fetch("SELECT * FROM users")) # DatabaseResult -> JSON array
482
485
  return response("<h1>Hello</h1>") # HTML
483
486
  return response("Not found", 404) # With status code
484
487
  return response.redirect("/login") # Redirect
@@ -487,6 +490,8 @@ return response.file("doc.pdf") # Serve a file
487
490
  return response.stream(generator) # SSE/streaming response (text/event-stream)
488
491
  ```
489
492
 
493
+ `response()` auto-serializes a domain object — an ORM model, a list of models, or a `DatabaseResult` — to JSON with no manual `to_dict()` / `to_json()`. A single model becomes a JSON object; a list of models or a `DatabaseResult` becomes a JSON array. Dicts, lists and strings behave exactly as before (purely additive).
494
+
490
495
  Add custom headers before returning:
491
496
  ```python
492
497
  from tina4_python.core.response import Response
@@ -821,19 +826,37 @@ class User(ORM):
821
826
  email = StringField()
822
827
  ```
823
828
 
824
- Initialize in `app.py`:
829
+ **Binding the database:**
830
+
831
+ - **`.env` default (no call needed)** — models auto-bind to `TINA4_DATABASE_URL` when it is set, so most apps need no binding call at all.
832
+ - **`bind_database(db)`** — override the default explicitly; assigns the connection to all ORM subclasses that don't select a named one.
833
+ - **`bind_database(db, name="analytics")` + `_db = "analytics"`** — register a named/secondary connection and point a model at it. A missing named connection raises a clear error.
834
+
825
835
  ```python
826
- from tina4_python.orm import orm_bind
836
+ from tina4_python.orm import bind_database
827
837
  from tina4_python.database import Database
828
838
 
829
- orm_bind(Database("sqlite:///app.db")) # Assigns DB to all ORM subclasses
839
+ # Most apps: nothing to do the .env default (TINA4_DATABASE_URL) is auto-bound.
840
+
841
+ # Override the default explicitly:
842
+ bind_database(Database("sqlite:///app.db")) # Assigns DB to all ORM subclasses
843
+
844
+ # Register a NAMED connection and point a model at it:
845
+ bind_database(Database("postgres://…/analytics", "u", "p"), name="analytics")
846
+
847
+ class Visit(ORM):
848
+ _db = "analytics" # this model uses the analytics connection
830
849
  ```
831
850
 
832
851
  ### ORM operations
833
852
 
853
+ The constructor accepts a dict, a JSON object string, or keyword args. Passing a list/array raises a clear `TypeError` (map over the list to build many records).
854
+
834
855
  ```python
835
856
  # Create
836
- user = User({"name": "Alice", "email": "alice@example.com"})
857
+ user = User({"name": "Alice", "email": "alice@example.com"}) # dict
858
+ user = User('{"name": "Alice", "email": "alice@example.com"}') # JSON object string
859
+ user = User(name="Alice", email="alice@example.com") # keyword args
837
860
  user.save()
838
861
 
839
862
  # Load — alias for select_one (class method, returns instance or None)
@@ -1567,7 +1590,7 @@ Key `.env` settings:
1567
1590
 
1568
1591
  ```bash
1569
1592
  # Authentication
1570
- SECRET=your-jwt-secret # JWT signing (default uses insecure placeholder)
1593
+ TINA4_SECRET=your-jwt-secret # JWT signing (warns + uses a blank, insecure secret if unset)
1571
1594
  TINA4_API_KEY=your-api-key # Static bearer token for API auth (API_KEY fallback supported)
1572
1595
  TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
1573
1596
 
@@ -1813,7 +1836,7 @@ async def dashboard(request, response):
1813
1836
  ## v3 Features Summary
1814
1837
 
1815
1838
  - **55 built-in features**, zero third-party dependencies
1816
- - **2,299 tests** passing across all modules
1839
+ - **2,857 tests** passing across all modules
1817
1840
  - **Production server auto-detect**: `tina4python serve --production` auto-installs uvicorn
1818
1841
  - **`tina4python generate`**: model, route, migration, middleware scaffolding
1819
1842
  - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`, `cache_stats()`, `cache_clear()`)
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.16"
11
+ __version__ = "3.13.21"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -34,7 +34,7 @@ from tina4_python.database import Database # noqa: E402, F401
34
34
 
35
35
  # ── ORM ──
36
36
  from tina4_python.orm import ( # noqa: E402, F401
37
- ORM, orm_bind, Field,
37
+ ORM, bind_database, Field,
38
38
  IntegerField, StringField, BooleanField, FloatField,
39
39
  DateTimeField, TextField, BlobField, NumericField,
40
40
  ForeignKeyField,
@@ -29,7 +29,7 @@ import functools
29
29
  class _DualMethod:
30
30
  """Descriptor that makes a method callable on both the class and an instance.
31
31
 
32
- - ``Auth.get_token(payload)`` — creates a temporary Auth() using env SECRET
32
+ - ``Auth.get_token(payload)`` — creates a temporary Auth() using env TINA4_SECRET
33
33
  - ``auth.get_token(payload)`` — uses the instance's own secret / settings
34
34
  """
35
35
 
@@ -42,13 +42,35 @@ class _DualMethod:
42
42
  # Called on the class: Auth.get_token(payload, ...)
43
43
  @functools.wraps(self._func)
44
44
  def _class_call(*args, **kwargs):
45
- inst = cls() # Auth() reads SECRET from env
45
+ inst = cls() # Auth() reads TINA4_SECRET from env
46
46
  return self._func(inst, *args, **kwargs)
47
47
  return _class_call
48
48
  # Called on an instance: auth_instance.get_token(payload, ...)
49
49
  return functools.partial(self._func, obj)
50
50
 
51
51
 
52
+ def _resolve_secret(secret: str = None) -> str:
53
+ """Resolve the JWT signing secret.
54
+
55
+ Reads ``TINA4_SECRET`` from the environment. When neither an explicit
56
+ secret nor ``TINA4_SECRET`` is set, warns loudly and returns a blank
57
+ secret — parity with the PHP/Node frameworks. Tina4 never silently signs
58
+ with a guessable built-in default, which would make tokens forgeable.
59
+ """
60
+ if secret:
61
+ return secret
62
+ env_secret = os.environ.get("TINA4_SECRET", "")
63
+ if not env_secret:
64
+ try:
65
+ from tina4_python.debug import Log
66
+ Log.warning("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)")
67
+ except Exception:
68
+ import sys
69
+ print("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)", file=sys.stderr)
70
+ return ""
71
+ return env_secret
72
+
73
+
52
74
  class Auth:
53
75
  """JWT authentication and password hashing — zero dependencies."""
54
76
 
@@ -56,11 +78,11 @@ class Auth:
56
78
  expires_in: int = None):
57
79
  """
58
80
  Args:
59
- secret: Signing secret (falls back to SECRET env var).
81
+ secret: Signing secret (falls back to the TINA4_SECRET env var).
60
82
  algorithm: JWT algorithm (default HS256).
61
83
  expires_in: Token lifetime in seconds (default 3600).
62
84
  """
63
- self.secret = secret or os.environ.get("TINA4_SECRET", "tina4-default-secret")
85
+ self.secret = secret if secret is not None else _resolve_secret()
64
86
  self.algorithm = algorithm
65
87
  # JWT expiry env var:
66
88
  # - TINA4_TOKEN_EXPIRES_IN (preferred — matches docs and the
@@ -80,7 +102,7 @@ class Auth:
80
102
 
81
103
  Args:
82
104
  expires_in: Lifetime in minutes (default: self.expires_in).
83
- secret: Override signing secret (default: self.secret / SECRET env var).
105
+ secret: Override signing secret (default: self.secret / TINA4_SECRET env var).
84
106
 
85
107
  Returns: header.payload.signature
86
108
  """
@@ -176,36 +198,36 @@ class Auth:
176
198
 
177
199
  # get_token and valid_token are the primary names
178
200
 
179
- # ── Class-level convenience methods (read SECRET from env) ────
201
+ # ── Class-level convenience methods (read TINA4_SECRET from env) ────
180
202
 
181
203
  @classmethod
182
204
  def get_token_static(cls, payload: dict, expires_in: int = 60) -> str:
183
- """Create a JWT without instantiating Auth — reads SECRET from env."""
184
- secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
205
+ """Create a JWT without instantiating Auth — reads TINA4_SECRET from env."""
206
+ secret = _resolve_secret()
185
207
  auth = cls(secret=secret, expires_in=expires_in)
186
208
  return auth.get_token(payload)
187
209
 
188
210
  @classmethod
189
211
  def valid_token_static(cls, token: str) -> "dict | None":
190
- """Validate a JWT without instantiating Auth — reads SECRET from env.
212
+ """Validate a JWT without instantiating Auth — reads TINA4_SECRET from env.
191
213
 
192
214
  Returns the decoded payload on success, ``None`` on failure.
193
215
  """
194
- secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
216
+ secret = _resolve_secret()
195
217
  auth = cls(secret=secret)
196
218
  return auth.valid_token(token)
197
219
 
198
220
  @classmethod
199
221
  def get_payload_static(cls, token: str) -> dict | None:
200
222
  """Decode payload (no validation) without instantiating Auth."""
201
- secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
223
+ secret = _resolve_secret()
202
224
  auth = cls(secret=secret)
203
225
  return auth.get_payload(token)
204
226
 
205
227
  @classmethod
206
228
  def refresh_token_static(cls, token: str, expires_in: int = 60) -> str | None:
207
- """Refresh a JWT without instantiating Auth — reads SECRET from env."""
208
- secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
229
+ """Refresh a JWT without instantiating Auth — reads TINA4_SECRET from env."""
230
+ secret = _resolve_secret()
209
231
  auth = cls(secret=secret, expires_in=expires_in)
210
232
  return auth.refresh_token(token)
211
233
 
@@ -213,10 +235,10 @@ class Auth:
213
235
  def authenticate_request_static(cls, headers: dict) -> dict | None:
214
236
  """Extract and validate auth from request headers without instantiating Auth.
215
237
 
216
- Reads SECRET from env. Checks: Bearer JWT, Bearer API key, Basic auth.
238
+ Reads TINA4_SECRET from env. Checks: Bearer JWT, Bearer API key, Basic auth.
217
239
  Returns payload dict on success, None on failure.
218
240
  """
219
- secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
241
+ secret = _resolve_secret()
220
242
  auth = cls(secret=secret)
221
243
  return auth.authenticate_request(headers)
222
244
 
@@ -281,7 +303,7 @@ class Auth:
281
303
  """Extract and validate auth from request headers.
282
304
 
283
305
  Args:
284
- secret: Override signing secret (default: self.secret / SECRET env var).
306
+ secret: Override signing secret (default: self.secret / TINA4_SECRET env var).
285
307
  algorithm: JWT algorithm override (default: "HS256").
286
308
 
287
309
  Checks: Bearer JWT, Bearer API key, Basic auth.
@@ -324,14 +346,14 @@ def _b64url_decode(s: str) -> bytes:
324
346
  # ── Module-level convenience functions (use static methods) ────
325
347
 
326
348
  def get_token(payload: dict, expires_in: int = 60, secret: str = None) -> str:
327
- """Create a JWT — reads SECRET from env. Shortcut for Auth.get_token_static()."""
349
+ """Create a JWT — reads TINA4_SECRET from env. Shortcut for Auth.get_token_static()."""
328
350
  if secret is not None:
329
351
  return Auth(secret=secret).get_token(payload, expires_in=expires_in)
330
352
  return Auth.get_token_static(payload, expires_in=expires_in)
331
353
 
332
354
 
333
355
  def valid_token(token: str) -> "dict | None":
334
- """Validate a JWT signature and expiry — reads SECRET from env.
356
+ """Validate a JWT signature and expiry — reads TINA4_SECRET from env.
335
357
 
336
358
  Returns the decoded payload dict on success, ``None`` on failure.
337
359
  The truthy/falsy split keeps legacy ``if valid_token(t):`` code working
@@ -346,12 +368,12 @@ def get_payload(token: str) -> dict | None:
346
368
 
347
369
 
348
370
  def refresh_token(token: str, expires_in: int = 60) -> str | None:
349
- """Refresh a JWT — reads SECRET from env. Shortcut for Auth.refresh_token_static()."""
371
+ """Refresh a JWT — reads TINA4_SECRET from env. Shortcut for Auth.refresh_token_static()."""
350
372
  return Auth.refresh_token_static(token, expires_in=expires_in)
351
373
 
352
374
 
353
375
  def authenticate_request(headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
354
- """Validate auth from request headers — reads SECRET from env."""
376
+ """Validate auth from request headers — reads TINA4_SECRET from env."""
355
377
  if secret is not None:
356
378
  return Auth(secret=secret).authenticate_request(headers, algorithm=algorithm)
357
379
  return Auth.authenticate_request_static(headers)
@@ -286,8 +286,8 @@ class CsrfMiddleware:
286
286
  if auth_header.startswith("Bearer "):
287
287
  bearer_token = auth_header[7:].strip()
288
288
  if bearer_token:
289
- from tina4_python.auth import Auth as _CsrfAuth
290
- secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
289
+ from tina4_python.auth import Auth as _CsrfAuth, _resolve_secret
290
+ secret = _resolve_secret()
291
291
  auth = _CsrfAuth(secret=secret)
292
292
  if auth.valid_token(bearer_token):
293
293
  return request, response
@@ -325,8 +325,8 @@ class CsrfMiddleware:
325
325
  )
326
326
 
327
327
  # Validate the token
328
- from tina4_python.auth import Auth as _CsrfAuth
329
- secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
328
+ from tina4_python.auth import Auth as _CsrfAuth, _resolve_secret
329
+ secret = _resolve_secret()
330
330
  auth = _CsrfAuth(secret=secret)
331
331
  if not auth.valid_token(token):
332
332
  return request, response.error(
@@ -70,6 +70,34 @@ def set_frond(engine):
70
70
  _global_frond = engine
71
71
 
72
72
 
73
+ def _to_jsonable(data):
74
+ """Normalise domain objects into JSON-serialisable structures.
75
+
76
+ Lets a route hand domain objects straight to ``response(...)`` /
77
+ ``response.json(...)`` without calling ``.to_dict()``/``.to_json()`` by hand:
78
+
79
+ return response(user) # ORM model -> dict
80
+ return response(User.all()) # list[ORM] -> list[dict]
81
+ return response(db.fetch(sql)) # DatabaseResult -> list[dict]
82
+
83
+ Plain ``dict`` / ``str`` / ``bytes`` / ``None`` pass through unchanged, so
84
+ existing handlers behave exactly as before.
85
+ """
86
+ if data is None or isinstance(data, (dict, str, bytes)):
87
+ return data
88
+ # Query result (DatabaseResult): exposes both a ``records`` list and a
89
+ # ``to_array`` method — the pair distinguishes it from a plain {"records": …} dict.
90
+ if isinstance(getattr(data, "records", None), list) and callable(getattr(data, "to_array", None)):
91
+ return data.records
92
+ # ORM model: duck-typed on a callable ``to_dict``.
93
+ if callable(getattr(data, "to_dict", None)):
94
+ return data.to_dict()
95
+ # Collections: normalise each element (list of models -> list of dicts).
96
+ if isinstance(data, (list, tuple)):
97
+ return [_to_jsonable(item) for item in data]
98
+ return data
99
+
100
+
73
101
  class Response:
74
102
  """HTTP response builder with compression and ETag support."""
75
103
 
@@ -107,6 +135,10 @@ class Response:
107
135
  for k, v in headers.items():
108
136
  self._headers.append((k, v))
109
137
 
138
+ # Normalise ORM models / collections / query results so handlers can
139
+ # `return response(model)` without serialising by hand.
140
+ data = _to_jsonable(data)
141
+
110
142
  if content_type:
111
143
  # Explicit content type provided
112
144
  self.content_type = content_type
@@ -232,7 +264,7 @@ class Response:
232
264
  if status_code:
233
265
  self.status_code = status_code
234
266
  self.content_type = "application/json"
235
- self.content = json.dumps(data, default=str, separators=(",", ":")).encode()
267
+ self.content = json.dumps(_to_jsonable(data), default=str, separators=(",", ":")).encode()
236
268
  return self
237
269
 
238
270
  def html(self, content: str, status_code: int = None) -> "Response":
@@ -15,7 +15,7 @@ from functools import lru_cache
15
15
  from pathlib import Path
16
16
  from datetime import datetime
17
17
 
18
- from tina4_python.auth import Auth as _FrondAuth
18
+ from tina4_python.auth import Auth as _FrondAuth, _resolve_secret
19
19
 
20
20
 
21
21
  class SafeString(str):
@@ -1173,7 +1173,7 @@ def _generate_form_jwt(descriptor: str = "", session_id: str = "") -> str:
1173
1173
  if sid:
1174
1174
  payload["session_id"] = sid
1175
1175
 
1176
- secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
1176
+ secret = _resolve_secret()
1177
1177
  ttl = int(os.environ.get("TINA4_TOKEN_EXPIRES_IN", "60"))
1178
1178
  auth = _FrondAuth(secret=secret, expires_in=ttl)
1179
1179
  return auth.get_token(payload)
@@ -24,10 +24,10 @@ from tina4_python.orm.fields import (
24
24
  IntField, StrField, BoolField, # short aliases
25
25
  has_many, has_one, belongs_to, # relationship descriptors
26
26
  )
27
- from tina4_python.orm.model import ORM, orm_bind
27
+ from tina4_python.orm.model import ORM, bind_database
28
28
 
29
29
  __all__ = [
30
- "ORM", "orm_bind",
30
+ "ORM", "bind_database",
31
31
  "Field",
32
32
  # Verbose (preferred)
33
33
  "IntegerField", "StringField", "BooleanField", "FloatField",
@@ -17,13 +17,13 @@ from tina4_python.core.cache import Cache
17
17
  # Module-level query cache — shared across all ORM models
18
18
  _query_cache = Cache(default_ttl=0, max_size=500)
19
19
 
20
- # Global database reference — set via orm_bind()
20
+ # Global database reference — set via bind_database()
21
21
  _database = None
22
22
  # Named database connections registry
23
23
  _databases: dict[str, object] = {}
24
24
 
25
25
 
26
- def orm_bind(db, name: str = None):
26
+ def bind_database(db, name: str = None):
27
27
  """Bind a Database instance to ORM models.
28
28
 
29
29
  Args:
@@ -32,11 +32,11 @@ def orm_bind(db, name: str = None):
32
32
  If None, sets the global default used by all models without _db.
33
33
 
34
34
  Usage:
35
- orm_bind(db_main) # default for all models
36
- orm_bind(db_audit, name="audit") # named connection
35
+ bind_database(db_main) # default for all models
36
+ bind_database(db_audit, name="audit") # named connection
37
37
 
38
38
  # Decorator style — class is returned unchanged:
39
- @orm_bind(db)
39
+ @bind_database(db)
40
40
  class User(ORM):
41
41
  ...
42
42
 
@@ -49,7 +49,7 @@ def orm_bind(db, name: str = None):
49
49
  else:
50
50
  _databases[name] = db
51
51
 
52
- # Return a pass-through decorator so @orm_bind(db) syntax works.
52
+ # Return a pass-through decorator so @bind_database(db) syntax works.
53
53
  # Without this the decorator would set the class to None.
54
54
  def _decorator(cls):
55
55
  return cls
@@ -191,6 +191,16 @@ class ORM(metaclass=ORMMeta):
191
191
  import json
192
192
  data = json.loads(data)
193
193
 
194
+ # A single model is one record — reject a list/array with a clear
195
+ # message instead of a cryptic "'list' object has no attribute 'items'".
196
+ if data is not None and not isinstance(data, dict):
197
+ raise TypeError(
198
+ f"{type(self).__name__}() expects a dict, a JSON object string, or "
199
+ f"keyword args for one record — got {type(data).__name__}. To build "
200
+ f"many records, map over the list: "
201
+ f"[{type(self).__name__}(row) for row in rows]."
202
+ )
203
+
194
204
  # Populate from dict or kwargs
195
205
  if data:
196
206
  self._populate(data)
@@ -269,7 +279,7 @@ class ORM(metaclass=ORMMeta):
269
279
  Resolution order:
270
280
  1. cls._db as a Database instance (direct assignment)
271
281
  2. cls._db as a string name → look up in _databases registry
272
- 3. Global _database (set via orm_bind(db))
282
+ 3. Global _database (set via bind_database(db))
273
283
  """
274
284
  if cls._db is not None:
275
285
  if isinstance(cls._db, str):
@@ -277,7 +287,7 @@ class ORM(metaclass=ORMMeta):
277
287
  if db is None:
278
288
  raise RuntimeError(
279
289
  f"Named database '{cls._db}' not found. "
280
- f"Call orm_bind(db, name='{cls._db}') first."
290
+ f"Call bind_database(db, name='{cls._db}') first."
281
291
  )
282
292
  return db
283
293
  return cls._db # Direct Database instance
@@ -291,10 +301,10 @@ class ORM(metaclass=ORMMeta):
291
301
  username = os.environ.get("TINA4_DATABASE_USERNAME", "")
292
302
  password = os.environ.get("TINA4_DATABASE_PASSWORD", "")
293
303
  db = Database(url, username, password)
294
- orm_bind(db)
304
+ bind_database(db)
295
305
  return db
296
306
  raise RuntimeError(
297
- "No database bound. Call orm_bind(db) or set TINA4_DATABASE_URL in .env"
307
+ "No database bound. Call bind_database(db) or set TINA4_DATABASE_URL in .env"
298
308
  )
299
309
  return _database
300
310
 
@@ -890,7 +900,7 @@ class ORM(metaclass=ORMMeta):
890
900
  Resolution order matches _get_db():
891
901
  1. cls._db as a Database instance
892
902
  2. cls._db as a named string → registry lookup
893
- 3. Global default set via orm_bind()
903
+ 3. Global default set via bind_database()
894
904
  """
895
905
  return cls._get_db()
896
906
 
File without changes