tina4-python 3.13.51__tar.gz → 3.13.53__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 (160) hide show
  1. {tina4_python-3.13.51 → tina4_python-3.13.53}/PKG-INFO +1 -1
  2. {tina4_python-3.13.51 → tina4_python-3.13.53}/pyproject.toml +1 -1
  3. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/CLAUDE.md +3 -1
  4. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/server.py +6 -0
  6. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/connection.py +1 -0
  7. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/frond/FROND.md +79 -0
  8. tina4_python-3.13.53/tina4_python/frond/__init__.py +85 -0
  9. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/frond/engine.py +109 -0
  10. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/graphql/__init__.py +2 -1
  11. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/orm/__init__.py +2 -2
  12. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/orm/fields.py +86 -0
  13. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/orm/model.py +23 -2
  14. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/js/frond.js +138 -1
  15. tina4_python-3.13.53/tina4_python/public/js/frond.min.js +2 -0
  16. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/__init__.py +49 -1
  17. tina4_python-3.13.51/tina4_python/frond/__init__.py +0 -12
  18. tina4_python-3.13.51/tina4_python/public/js/frond.min.js +0 -2
  19. {tina4_python-3.13.51 → tina4_python-3.13.53}/.gitignore +0 -0
  20. {tina4_python-3.13.51 → tina4_python-3.13.53}/README.md +0 -0
  21. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/HtmlElement.py +0 -0
  22. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/Testing.py +0 -0
  23. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/ai/__init__.py +0 -0
  24. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/api/__init__.py +0 -0
  25. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/auth/__init__.py +0 -0
  26. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/cache/__init__.py +0 -0
  27. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/cli/__init__.py +0 -0
  28. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/container/__init__.py +0 -0
  29. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/__init__.py +0 -0
  30. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/cache.py +0 -0
  31. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/constants.py +0 -0
  32. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/events.py +0 -0
  33. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/middleware.py +0 -0
  34. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/rate_limiter.py +0 -0
  35. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/request.py +0 -0
  36. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/response.py +0 -0
  37. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/core/router.py +0 -0
  38. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/crud/__init__.py +0 -0
  39. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/__init__.py +0 -0
  40. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/adapter.py +0 -0
  41. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/firebird.py +0 -0
  42. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/mongodb.py +0 -0
  43. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/mssql.py +0 -0
  44. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/mysql.py +0 -0
  45. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/odbc.py +0 -0
  46. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/postgres.py +0 -0
  47. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/database/sqlite.py +0 -0
  48. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/debug/__init__.py +0 -0
  49. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/debug/error_overlay.py +0 -0
  50. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/dev_admin/__init__.py +0 -0
  51. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/dev_admin/metrics.py +0 -0
  52. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/dev_admin/plan.py +0 -0
  53. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/dev_admin/project_index.py +0 -0
  54. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/docs.py +0 -0
  55. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/docstore/__init__.py +0 -0
  56. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/dotenv/__init__.py +0 -0
  57. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/env.py +0 -0
  58. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/auth/meta.json +0 -0
  59. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  60. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/database/meta.json +0 -0
  61. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  62. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/error-overlay/meta.json +0 -0
  63. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  64. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/orm/meta.json +0 -0
  65. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  66. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  67. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/queue/meta.json +0 -0
  68. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  69. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/rest-api/meta.json +0 -0
  70. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  71. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/templates/meta.json +0 -0
  72. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  73. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  74. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/i18n/__init__.py +0 -0
  75. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/mcp/__init__.py +0 -0
  76. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/mcp/protocol.py +0 -0
  77. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/mcp/tools.py +0 -0
  78. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/messenger/__init__.py +0 -0
  79. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/migration/__init__.py +0 -0
  80. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/migration/runner.py +0 -0
  81. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/__feedback/widget.js +0 -0
  82. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/css/tina4.css +0 -0
  83. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/css/tina4.min.css +0 -0
  84. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/favicon.ico +0 -0
  85. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/images/logo.svg +0 -0
  86. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  87. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  88. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  89. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/js/tina4.min.js +0 -0
  90. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/js/tina4js.min.js +0 -0
  91. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/swagger/index.html +0 -0
  92. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  93. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/query_builder/__init__.py +0 -0
  94. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue/__init__.py +0 -0
  95. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue/job.py +0 -0
  96. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue/kafka_backend.py +0 -0
  97. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue/lite_backend.py +0 -0
  98. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue/mongo_backend.py +0 -0
  99. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue_backends/__init__.py +0 -0
  101. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue_backends/kafka_backend.py +0 -0
  102. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue_backends/mongo_backend.py +0 -0
  103. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  104. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  105. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_badges.scss +0 -0
  106. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  107. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_cards.scss +0 -0
  108. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_forms.scss +0 -0
  109. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_grid.scss +0 -0
  110. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_modals.scss +0 -0
  111. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_nav.scss +0 -0
  112. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_reset.scss +0 -0
  113. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_tables.scss +0 -0
  114. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_typography.scss +0 -0
  115. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  116. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/_variables.scss +0 -0
  117. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/base.scss +0 -0
  118. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/colors.scss +0 -0
  119. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/scss/tina4css/tina4.scss +0 -0
  120. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/seeder/__init__.py +0 -0
  121. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/service/__init__.py +0 -0
  122. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/session/__init__.py +0 -0
  123. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/session_handlers/__init__.py +0 -0
  124. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  125. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/session_handlers/redis_handler.py +0 -0
  126. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/session_handlers/valkey_handler.py +0 -0
  127. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/swagger/__init__.py +0 -0
  128. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/components/crud.twig +0 -0
  129. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  130. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  131. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/docker/python/Dockerfile +0 -0
  132. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  133. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/errors/302.twig +0 -0
  134. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/errors/401.twig +0 -0
  135. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/errors/403.twig +0 -0
  136. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/errors/404.twig +0 -0
  137. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/errors/500.twig +0 -0
  138. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/errors/502.twig +0 -0
  139. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/errors/503.twig +0 -0
  140. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/errors/base.twig +0 -0
  141. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/frontend/README.md +0 -0
  142. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/templates/readme.md +0 -0
  143. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/test/__init__.py +0 -0
  144. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/test_client/__init__.py +0 -0
  145. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  156. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  157. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/validator/__init__.py +0 -0
  158. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/websocket/__init__.py +0 -0
  159. {tina4_python-3.13.51 → tina4_python-3.13.53}/tina4_python/websocket/backplane.py +0 -0
  160. {tina4_python-3.13.51 → tina4_python-3.13.53}/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.51
3
+ Version: 3.13.53
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.51"
3
+ version = "3.13.53"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -901,7 +901,9 @@ User.query().where("name = ?", ["Alice"]).to_mongo()
901
901
  ```
902
902
 
903
903
  ### Available field types
904
- `IntegerField`, `StringField`, `TextField`, `DateTimeField`, `NumericField`, `BlobField`, `BooleanField`, `FloatField`, `ForeignKeyField`
904
+ `IntegerField`, `StringField`, `TextField`, `DateTimeField`, `NumericField`, `BlobField`, `BooleanField`, `FloatField`, `JSONField`, `ForeignKeyField`
905
+
906
+ `JSONField()` stores a `dict`/`list` as JSON. You work with a plain Python object; the ORM serializes to a JSON string on save and parses it back on load, so `model.field` is always a `dict`/`list`. Engine-aware column: `JSONB` (PostgreSQL), `JSON` (MySQL), `NVARCHAR(MAX)` (MSSQL), `TEXT` (SQLite), `BLOB SUB_TYPE TEXT` (Firebird). Use it for a JSON column inside a relational row; use DocStore for whole-document storage with Mongo-style queries.
905
907
 
906
908
  Import from `tina4_python` or `tina4_python.orm.fields`.
907
909
 
@@ -36,7 +36,7 @@ from tina4_python.database import Database # noqa: E402, F401
36
36
  from tina4_python.orm import ( # noqa: E402, F401
37
37
  ORM, bind_database, Field,
38
38
  IntegerField, StringField, BooleanField, FloatField,
39
- DateTimeField, TextField, BlobField, NumericField,
39
+ DateTimeField, TextField, BlobField, NumericField, JSONField,
40
40
  ForeignKeyField,
41
41
  IntField, StrField, BoolField, # short aliases
42
42
  has_many, has_one, belongs_to, # relationship descriptors
@@ -276,6 +276,12 @@ Router.add("GET", _HEALTH_PATH, _health_handler)
276
276
  if _HEALTH_PATH != "/health":
277
277
  Router.add("GET", "/health", _health_handler)
278
278
 
279
+ # Frond live blocks: re-render a registered {% live %} fragment on demand.
280
+ # Always on (production too) - the poll/sse client fetches this; auth re-applies
281
+ # through the normal middleware chain on every refresh.
282
+ from tina4_python.frond import live_endpoint as _live_endpoint
283
+ Router.add("GET", "/__frond/live/{name}", _live_endpoint)
284
+
279
285
 
280
286
  def _render_error_page(status_code: int, path: str, request_id: str, error_message: str = "") -> str | None:
281
287
  """Render a styled error page using Frond engine.
@@ -110,6 +110,7 @@ except ImportError:
110
110
  from tina4_python.database.postgres import PostgreSQLAdapter
111
111
  register_driver("postgresql", PostgreSQLAdapter)
112
112
  register_driver("postgres", PostgreSQLAdapter)
113
+ register_driver("pgsql", PostgreSQLAdapter) # PDO / Laravel / Doctrine scheme name (issue #58)
113
114
 
114
115
  # Register MySQL (mysql-connector-python — optional)
115
116
  from tina4_python.database.mysql import MySQLAdapter
@@ -383,6 +383,85 @@ Macros don't inherit parent context — pass everything explicitly via parameter
383
383
 
384
384
  ---
385
385
 
386
+ ## Live Blocks
387
+
388
+ A live block renders on the server, then keeps itself fresh. The first paint ships with the
389
+ page, so there is no loading flash. After that, the block re-fetches its own HTML and swaps
390
+ it in place. You write server-side Frond and the block stays current, with no hand-written
391
+ JavaScript.
392
+
393
+ ```twig
394
+ {% live "cart" poll 5 %}
395
+ <strong>{{ count }}</strong> items - {{ total | number_format(2) }}
396
+ {% endlive %}
397
+ ```
398
+
399
+ That block paints once with the page, then re-renders every 5 seconds. `frond.js` (already
400
+ loaded from `/js/frond.js`) finds the marker, calls `GET /__frond/live/cart`, and morphs the
401
+ returned HTML into place. A focused input and its caret survive the swap.
402
+
403
+ ### Transports
404
+
405
+ Pick how the block refreshes:
406
+
407
+ ```twig
408
+ {% live "cart" poll 5 %}...{% endlive %} {# re-fetch every 5 seconds #}
409
+ {% live "feed" sse %}...{% endlive %} {# Server-Sent Events stream #}
410
+ {% live "chat" ws "/ws/chat" %}...{% endlive %} {# WebSocket on /ws/chat #}
411
+ ```
412
+
413
+ `poll N` pulls every `N` seconds. `sse` and `ws` push: the server decides when to send. All
414
+ three render the same server-side body. Only the delivery changes.
415
+
416
+ ### The data source
417
+
418
+ A live block needs data on every refresh, not just the first paint. Register a provider by
419
+ name with `@live_source`. It runs on each refresh with the live request, so the block
420
+ re-renders against fresh data and re-applies auth every time. An unauthenticated caller never
421
+ sees another user's numbers.
422
+
423
+ ```python
424
+ from tina4_python.frond import live_source
425
+
426
+ @live_source("cart")
427
+ def cart_data(request):
428
+ user = request.session.get("user_id")
429
+ return {"count": cart_count(user), "total": cart_total(user)}
430
+ ```
431
+
432
+ The provider feeds the always-on endpoint `GET /__frond/live/{name}`. There is no route to
433
+ write. The block name is the route.
434
+
435
+ ### Pushing updates
436
+
437
+ A `ws` block can update the moment data changes, without waiting for the next poll. Call
438
+ `push_live(name, data)`. It re-renders the block and broadcasts the new HTML to every client
439
+ connected on the block's WebSocket path.
440
+
441
+ ```python
442
+ from tina4_python.frond import push_live
443
+
444
+ # after an order lands:
445
+ push_live("cart", {"count": 3, "total": 59.97})
446
+ ```
447
+
448
+ ### Same-origin only
449
+
450
+ A block can point at your own route with `src` instead of the auto endpoint:
451
+
452
+ ```twig
453
+ {% live "cart" poll 5 src "/fragments/cart" %}...{% endlive %}
454
+ ```
455
+
456
+ `src` must be a same-origin path. An absolute URL is rejected at render time, so a live block
457
+ never fetches from a host you did not write. Nested live blocks are rejected too.
458
+
459
+ The marker element is byte-identical across Python, PHP, Ruby, and Node, so the shared
460
+ `frond.js` drives every backend the same way. Write the block once. It renders anywhere
461
+ Tina4 runs.
462
+
463
+ ---
464
+
386
465
  ## Comments
387
466
 
388
467
  ```twig
@@ -0,0 +1,85 @@
1
+ # Tina4 Frond — Zero-dependency template engine.
2
+ """
3
+ Twig-like template engine built from scratch.
4
+
5
+ from tina4_python.frond import Frond
6
+
7
+ engine = Frond(template_dir="src/templates")
8
+ output = engine.render("page.html", {"name": "World"})
9
+ """
10
+ import inspect
11
+
12
+ from tina4_python.frond.engine import Frond
13
+
14
+
15
+ async def live_endpoint(name, request, response):
16
+ """GET /__frond/live/{name} - re-render a registered {% live %} fragment.
17
+
18
+ Resolves the @live_source provider for <name>, runs it with the LIVE request
19
+ so auth and session scoping re-apply on every refresh (a live "my cart"
20
+ cannot leak another user's data), renders the registered fragment, and
21
+ returns the HTML. 404 when the name is unknown or its fragment has not been
22
+ registered yet (the page carrying the block has not rendered).
23
+ """
24
+ provider = Frond._class_live_sources.get(name)
25
+ if name not in Frond._class_live_fragments and provider is None:
26
+ return response("live block not found: " + str(name), 404)
27
+ context = {}
28
+ if provider is not None:
29
+ result = provider(request)
30
+ if inspect.isawaitable(result):
31
+ result = await result
32
+ context = result or {}
33
+ html = Frond.render_live(name, context)
34
+ if html is None:
35
+ return response("live fragment not registered yet: " + str(name), 404)
36
+ return response(html)
37
+
38
+
39
+ async def push_live(name, data=None):
40
+ """Re-render the '<name>' live fragment and push it to connected clients.
41
+
42
+ Renders via Frond.render_live(name, data) and broadcasts a {type, name, html}
43
+ envelope over WebSocket: to the ws path the block declared (data-ws), or to a
44
+ room named <name> if the block declared none. Apps call this after a state
45
+ change (a new chat message, an order update). Returns the rendered HTML, or
46
+ None when the fragment is not registered (its page has not rendered).
47
+ """
48
+ html = Frond.render_live(name, data or {})
49
+ if html is None:
50
+ return None
51
+ import json
52
+ envelope = json.dumps({"type": "live", "name": name, "html": html})
53
+ try:
54
+ from tina4_python.core.server import _ws_manager
55
+ ws_path = Frond._class_live_ws_paths.get(name)
56
+ if ws_path:
57
+ await _ws_manager.broadcast(envelope, path=ws_path)
58
+ else:
59
+ await _ws_manager.broadcast_to_room(name, envelope)
60
+ except Exception as exc:
61
+ try:
62
+ from tina4_python.debug import Log
63
+ Log.error("push_live(" + str(name) + ") broadcast failed: " + str(exc))
64
+ except Exception:
65
+ pass
66
+ return html
67
+
68
+
69
+ def live_source(name: str):
70
+ """Register a data provider for a {% live %} block.
71
+
72
+ The decorated function receives the request and returns the context dict
73
+ used to re-render the fragment on refresh:
74
+
75
+ @live_source("notifications")
76
+ async def notifications(request):
77
+ return {"items": Notification.where("user_id = ?", [request.session["uid"]])}
78
+ """
79
+ def _wrap(fn):
80
+ Frond._class_live_sources[name] = fn
81
+ return fn
82
+ return _wrap
83
+
84
+
85
+ __all__ = ["Frond", "live_source", "live_endpoint", "push_live"]
@@ -117,6 +117,15 @@ _FILTER_CMP_RE = re.compile(r"(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)")
117
117
  _FOR_RE = re.compile(r"for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)")
118
118
  _SET_RE = re.compile(r"set\s+(\w+)\s*=\s*(.+)", re.DOTALL)
119
119
  _INCLUDE_RE = re.compile(r'include\s+["\'](.+?)["\'](?:\s+with\s+(.+))?')
120
+ _LIVE_RE = re.compile(r'live\s+["\'](?P<name>[^"\']+)["\'](?P<rest>.*)$', re.DOTALL)
121
+ _LIVE_WS_RE = re.compile(r'ws\s+["\']([^"\']+)["\']')
122
+ _LIVE_SRC_RE = re.compile(r'src\s+["\']([^"\']+)["\']')
123
+
124
+
125
+ def _live_attr(value) -> str:
126
+ """Escape a value for use inside an HTML attribute on a live marker."""
127
+ return (str(value).replace("&", "&amp;").replace('"', "&quot;")
128
+ .replace("<", "&lt;").replace(">", "&gt;"))
120
129
  _MACRO_RE = re.compile(r"macro\s+(\w+)\s*\(([^)]*)\)")
121
130
  _FROM_IMPORT_RE = re.compile(r'from\s+["\'](.+?)["\']\s+import\s+(.+)')
122
131
  _IMPORT_AS_RE = re.compile(r'import\s+["\'](.+?)["\']\s+as\s+(\w+)')
@@ -1309,6 +1318,11 @@ class Frond:
1309
1318
  _class_globals: dict = {}
1310
1319
  _class_filters: dict = {}
1311
1320
  _class_tests: dict = {}
1321
+ # Live blocks: name -> body template source (registered when a {% live %}
1322
+ # block renders); name -> data provider (registered via @live_source).
1323
+ _class_live_fragments: dict = {}
1324
+ _class_live_sources: dict = {}
1325
+ _class_live_ws_paths: dict = {}
1312
1326
 
1313
1327
  @classmethod
1314
1328
  def clear_registry(cls):
@@ -1320,6 +1334,9 @@ class Frond:
1320
1334
  cls._class_globals.clear()
1321
1335
  cls._class_filters.clear()
1322
1336
  cls._class_tests.clear()
1337
+ cls._class_live_fragments.clear()
1338
+ cls._class_live_sources.clear()
1339
+ cls._class_live_ws_paths.clear()
1323
1340
 
1324
1341
  def __init__(self, template_dir: str = "src/templates"):
1325
1342
  self.template_dir = Path(template_dir)
@@ -1766,6 +1783,11 @@ class Frond:
1766
1783
  output.append(result)
1767
1784
  i = skip
1768
1785
 
1786
+ elif tag == "live":
1787
+ result, skip = self._handle_live(tokens, i, context)
1788
+ output.append(result)
1789
+ i = skip
1790
+
1769
1791
  elif tag in ("block", "endblock", "extends"):
1770
1792
  i += 1 # Already handled
1771
1793
 
@@ -2396,6 +2418,93 @@ class Frond:
2396
2418
  self._fragment_cache[cache_key] = (rendered, time.time() + ttl)
2397
2419
  return rendered, i
2398
2420
 
2421
+ def _handle_live(self, tokens: list, start: int, context: dict) -> tuple[str, int]:
2422
+ """Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}.
2423
+
2424
+ Server-rendered live region. The body renders once for first paint, is
2425
+ registered under <name> so the /__frond/live/<name> endpoint (or a
2426
+ @live_source provider) can re-render it, and is wrapped in a marker
2427
+ element that frond.js wires to the chosen transport (poll / sse / ws).
2428
+ """
2429
+ content, _, _ = _strip_tag(tokens[start][1])
2430
+ m = _LIVE_RE.match(content)
2431
+ if not m:
2432
+ raise ValueError('live: expected {% live "name" poll N | sse | ws "path" %}')
2433
+ name = m.group("name")
2434
+ rest = (m.group("rest") or "").strip()
2435
+ parts = rest.split()
2436
+ mode = parts[0] if parts else ""
2437
+
2438
+ src = None
2439
+ sm = _LIVE_SRC_RE.search(rest)
2440
+ if sm:
2441
+ src = sm.group(1)
2442
+ if src and (src.startswith("http://") or src.startswith("https://") or src.startswith("//")):
2443
+ raise ValueError("live: src must be a same-origin path, not an absolute URL")
2444
+
2445
+ interval = None
2446
+ ws_path = None
2447
+ if mode == "poll":
2448
+ if len(parts) < 2 or not parts[1].isdigit():
2449
+ raise ValueError('live: poll requires seconds, e.g. {% live "x" poll 5 %}')
2450
+ interval = int(parts[1])
2451
+ elif mode == "sse":
2452
+ pass
2453
+ elif mode == "ws":
2454
+ wm = _LIVE_WS_RE.search(rest)
2455
+ if not wm:
2456
+ raise ValueError('live: ws requires a path, e.g. {% live "x" ws "/ws/x" %}')
2457
+ ws_path = wm.group(1)
2458
+ else:
2459
+ raise ValueError(f'live: unknown transport "{mode}" (use poll N, sse, or ws "path")')
2460
+
2461
+ # Collect body tokens up to {% endlive %}. Nested live is unsupported.
2462
+ body_tokens = []
2463
+ i = start + 1
2464
+ while i < len(tokens):
2465
+ if tokens[i][0] == BLOCK:
2466
+ tag_content, _, _ = _strip_tag(tokens[i][1])
2467
+ tag = tag_content.split()[0] if tag_content.split() else ""
2468
+ if tag == "live":
2469
+ raise ValueError("live: nested live blocks are not supported")
2470
+ if tag == "endlive":
2471
+ i += 1
2472
+ break
2473
+ body_tokens.append(tokens[i])
2474
+ else:
2475
+ body_tokens.append(tokens[i])
2476
+ i += 1
2477
+
2478
+ # Register the body source so the auto endpoint can re-render it.
2479
+ Frond._class_live_fragments[name] = "".join(raw for (_t, raw) in body_tokens)
2480
+
2481
+ endpoint = src or ("/__frond/live/" + name)
2482
+ attrs = [f'data-frond-live="{_live_attr(name)}"', f'id="live-{_live_attr(name)}"']
2483
+ if mode == "poll":
2484
+ attrs += ['data-mode="poll"', f'data-interval="{interval}"',
2485
+ f'data-src="{_live_attr(endpoint)}"']
2486
+ elif mode == "sse":
2487
+ attrs += ['data-mode="sse"', f'data-src="{_live_attr(endpoint)}"']
2488
+ elif mode == "ws":
2489
+ Frond._class_live_ws_paths[name] = ws_path
2490
+ attrs += ['data-mode="ws"', f'data-ws="{_live_attr(ws_path)}"']
2491
+
2492
+ first_paint = self._render_tokens(list(body_tokens), context)
2493
+ return f'<div {" ".join(attrs)}>{first_paint}</div>', i
2494
+
2495
+ @classmethod
2496
+ def render_live(cls, name: str, data: dict = None):
2497
+ """Re-render a registered {% live %} fragment by name.
2498
+
2499
+ Returns the rendered HTML fragment, or None if no fragment is registered
2500
+ under that name yet (the page carrying the block has not rendered). The
2501
+ /__frond/live/<name> endpoint calls this after resolving the provider data.
2502
+ """
2503
+ source = cls._class_live_fragments.get(name)
2504
+ if source is None:
2505
+ return None
2506
+ return cls().render_string(source, data or {})
2507
+
2399
2508
  def _handle_spaceless(self, tokens: list, start: int, context: dict) -> tuple[str, int]:
2400
2509
  """Handle {% spaceless %}...{% endspaceless %}.
2401
2510
 
@@ -382,7 +382,8 @@ class Schema:
382
382
  gql_type = "Float"
383
383
  elif ftype == "BooleanField":
384
384
  gql_type = "Boolean"
385
- elif ftype in ("StringField", "TextField", "DateTimeField"):
385
+ elif ftype in ("StringField", "TextField", "DateTimeField", "JSONField"):
386
+ # JSONField serializes to a JSON string in the GraphQL payload.
386
387
  gql_type = "String"
387
388
  else:
388
389
  gql_type = "String"
@@ -19,7 +19,7 @@ Active Record ORM with SQL-first paradigm.
19
19
  from tina4_python.orm.fields import (
20
20
  Field,
21
21
  IntegerField, StringField, BooleanField, FloatField,
22
- DateTimeField, TextField, BlobField, NumericField,
22
+ DateTimeField, TextField, BlobField, NumericField, JSONField,
23
23
  ForeignKeyField,
24
24
  IntField, StrField, BoolField, # short aliases
25
25
  has_many, has_one, belongs_to, # relationship descriptors
@@ -31,7 +31,7 @@ __all__ = [
31
31
  "Field",
32
32
  # Verbose (preferred)
33
33
  "IntegerField", "StringField", "BooleanField", "FloatField",
34
- "DateTimeField", "TextField", "BlobField", "NumericField",
34
+ "DateTimeField", "TextField", "BlobField", "NumericField", "JSONField",
35
35
  "ForeignKeyField",
36
36
  # Short aliases (backwards compat)
37
37
  "IntField", "StrField", "BoolField",
@@ -9,6 +9,8 @@ Fields define column types, constraints, and defaults for ORM models.
9
9
  role = Field(str, choices=["admin", "user", "guest"])
10
10
  active = Field(bool, default=True)
11
11
  """
12
+ import copy as _copy
13
+ import json as _json
12
14
  import re as _re
13
15
  from datetime import datetime
14
16
 
@@ -165,6 +167,16 @@ class Field:
165
167
 
166
168
  return value
167
169
 
170
+ def to_db(self, value):
171
+ """Serialize an in-memory value to the form the driver should store.
172
+
173
+ Identity by default — the value already round-trips through the driver
174
+ as-is. Overridden by fields whose Python representation differs from
175
+ their column representation (e.g. ``JSONField`` stores a JSON string).
176
+ Called by ``ORM.save()`` when building the row for INSERT/UPDATE.
177
+ """
178
+ return value
179
+
168
180
  def __repr__(self):
169
181
  parts = [self.field_type.__name__]
170
182
  if self.primary_key:
@@ -212,6 +224,80 @@ def BlobField(**kwargs):
212
224
  def NumericField(**kwargs):
213
225
  return _make_field(float, "NumericField", **kwargs)
214
226
 
227
+ class JSONField(Field):
228
+ """A column that stores a JSON document (a ``dict`` or a ``list``).
229
+
230
+ You work with a plain Python ``dict``/``list``; the ORM serializes it to a
231
+ JSON string on save and parses it back on load. The column DDL is
232
+ engine-aware (see :meth:`ORM.create_table`): ``JSONB`` on PostgreSQL,
233
+ ``JSON`` on MySQL, ``NVARCHAR(MAX)`` on MSSQL, ``TEXT`` on SQLite/ODBC,
234
+ ``BLOB SUB_TYPE TEXT`` on Firebird.
235
+
236
+ Read normalization: a native-JSON driver (PostgreSQL JSONB, MySQL JSON) may
237
+ hand back an already-parsed object, while a string-backed engine (SQLite)
238
+ hands back a JSON string. :meth:`validate` normalizes both to a Python
239
+ object, so ``model.data`` is always a ``dict``/``list`` regardless of engine.
240
+
241
+ Usage::
242
+
243
+ class Event(ORM):
244
+ id = IntegerField(primary_key=True, auto_increment=True)
245
+ payload = JSONField()
246
+
247
+ Event({"payload": {"type": "click", "tags": ["a", "b"]}}).save()
248
+ e = Event.find(1)
249
+ e.payload["type"] # "click" — a real dict, not a string
250
+ """
251
+
252
+ def __init__(self, **kwargs):
253
+ super().__init__(dict, **kwargs)
254
+ self.kind = "JSONField"
255
+
256
+ def _resolve_default(self):
257
+ # Deep-copy a mutable JSON default (``default={}`` / ``default=[]``) so
258
+ # separate instances never alias the same object — the classic
259
+ # shared-default footgun. This runs on BOTH the __init__ default path
260
+ # (which calls _resolve_default directly) and the validate() None path.
261
+ d = super()._resolve_default()
262
+ return _copy.deepcopy(d) if isinstance(d, (dict, list)) else d
263
+
264
+ def validate(self, value):
265
+ # None -> required check + default (mirrors base Field).
266
+ if value is None:
267
+ if self.required and self.default is None:
268
+ raise ValueError(f"Field '{self.name}' is required")
269
+ return self._resolve_default()
270
+
271
+ # A driver returns a JSON string (SQLite/TEXT) or an already-parsed
272
+ # dict/list (PostgreSQL JSONB, MySQL JSON). Normalize to a Python object.
273
+ if isinstance(value, (bytes, bytearray)):
274
+ value = value.decode("utf-8")
275
+ if isinstance(value, str):
276
+ try:
277
+ value = _json.loads(value)
278
+ except (ValueError, TypeError) as e:
279
+ raise ValueError(f"Field '{self.name}': value is not valid JSON") from e
280
+ if not isinstance(value, (dict, list)):
281
+ raise ValueError(
282
+ f"Field '{self.name}': expected a dict or list, got {type(value).__name__}"
283
+ )
284
+ if self.validator is not None:
285
+ self.validator(value)
286
+ return value
287
+
288
+ def to_db(self, value):
289
+ # Serialize to a JSON string for the driver. A TEXT column stores it
290
+ # verbatim; a native JSON column (JSONB/JSON) casts the string literal.
291
+ if value is None:
292
+ return None
293
+ if isinstance(value, str):
294
+ return value # already serialized (defensive)
295
+ try:
296
+ return _json.dumps(value)
297
+ except (TypeError, ValueError) as e:
298
+ raise ValueError(f"Field '{self.name}': value is not JSON-serializable") from e
299
+
300
+
215
301
  class ForeignKeyField(Field):
216
302
  """Integer field that references another model's primary key.
217
303
 
@@ -390,7 +390,9 @@ class ORM(metaclass=ORMMeta):
390
390
  if value is not None or not field.auto_increment:
391
391
  # Use field_mapping for the column name, fall back to field.column
392
392
  db_col = self.field_mapping.get(name, field.column)
393
- data[db_col] = value
393
+ # Serialize to the column's storage form: identity for most
394
+ # fields, JSON string for JSONField (see Field.to_db).
395
+ data[db_col] = field.to_db(value)
394
396
 
395
397
  # v3.13.11 (issue #50): pick INSERT vs UPDATE on row existence
396
398
  # for non-auto-increment PKs. Auto-increment keeps the legacy
@@ -840,6 +842,23 @@ class ORM(metaclass=ORMMeta):
840
842
  else:
841
843
  datetime_sql = "DATETIME"
842
844
 
845
+ # JSONField -> the engine's native JSON type where it has one, else a
846
+ # text column. PostgreSQL JSONB (binary, indexable, canonical form);
847
+ # MySQL JSON; MSSQL has no JSON type so NVARCHAR(MAX) (its documented
848
+ # JSON storage); SQLite/ODBC TEXT; Firebird has no TEXT/JSON type so
849
+ # BLOB SUB_TYPE TEXT. The ORM stores a JSON string in every case, so a
850
+ # text-backed column round-trips identically to a native one.
851
+ if engine in ("postgres", "postgresql"):
852
+ json_sql = "JSONB"
853
+ elif engine == "mysql":
854
+ json_sql = "JSON"
855
+ elif engine == "mssql":
856
+ json_sql = "NVARCHAR(MAX)"
857
+ elif engine == "firebird":
858
+ json_sql = "BLOB SUB_TYPE TEXT"
859
+ else:
860
+ json_sql = "TEXT"
861
+
843
862
  # Don't recreate if table already exists
844
863
  if db.table_exists(table):
845
864
  return True
@@ -866,6 +885,8 @@ class ORM(metaclass=ORMMeta):
866
885
  sql_type = datetime_sql
867
886
  elif kind == "BlobField":
868
887
  sql_type = "BLOB"
888
+ elif kind == "JSONField":
889
+ sql_type = json_sql
869
890
  else:
870
891
  # Fallback based on field_type
871
892
  ft = field_obj.field_type
@@ -886,7 +907,7 @@ class ORM(metaclass=ORMMeta):
886
907
  parts.append("AUTOINCREMENT")
887
908
  if field_obj.required and not field_obj.primary_key:
888
909
  parts.append("NOT NULL")
889
- if field_obj.default is not None and not field_obj.auto_increment:
910
+ if field_obj.default is not None and not field_obj.auto_increment and kind != "JSONField":
890
911
  default_val = field_obj.default
891
912
  if isinstance(default_val, str):
892
913
  parts.append(f"DEFAULT '{default_val}'")