tina4-python 3.13.99__tar.gz → 3.13.100__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 (159) hide show
  1. {tina4_python-3.13.99 → tina4_python-3.13.100}/PKG-INFO +1 -1
  2. {tina4_python-3.13.99 → tina4_python-3.13.100}/pyproject.toml +1 -1
  3. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/ai/__init__.py +21 -6
  5. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/frond/engine.py +236 -53
  6. {tina4_python-3.13.99 → tina4_python-3.13.100}/.gitignore +0 -0
  7. {tina4_python-3.13.99 → tina4_python-3.13.100}/README.md +0 -0
  8. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/CLAUDE.md +0 -0
  9. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/HtmlElement.py +0 -0
  10. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/Testing.py +0 -0
  11. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/api/__init__.py +0 -0
  12. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/auth/__init__.py +0 -0
  13. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/cache/__init__.py +0 -0
  14. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/cli/__init__.py +0 -0
  15. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/container/__init__.py +0 -0
  16. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/context/__init__.py +0 -0
  17. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/context/chunker.py +0 -0
  18. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/__init__.py +0 -0
  19. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/cache.py +0 -0
  20. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/constants.py +0 -0
  21. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/events.py +0 -0
  22. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/middleware.py +0 -0
  23. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/port_takeover.py +0 -0
  24. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/rate_limiter.py +0 -0
  25. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/request.py +0 -0
  26. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/response.py +0 -0
  27. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/router.py +0 -0
  28. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/core/server.py +0 -0
  29. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/crud/__init__.py +0 -0
  30. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/__init__.py +0 -0
  31. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/adapter.py +0 -0
  32. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/connection.py +0 -0
  33. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/database_url.py +0 -0
  34. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/mssql.py +0 -0
  37. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/mysql.py +0 -0
  38. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/odbc.py +0 -0
  39. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/postgres.py +0 -0
  40. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/sql_translator.py +0 -0
  41. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/database/sqlite.py +0 -0
  42. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/debug/__init__.py +0 -0
  43. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/debug/error_overlay.py +0 -0
  44. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/dev_admin/__init__.py +0 -0
  45. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/dev_admin/metrics.py +0 -0
  46. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/dev_admin/plan.py +0 -0
  47. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/dev_admin/project_index.py +0 -0
  48. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/docs.py +0 -0
  49. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/docstore/__init__.py +0 -0
  50. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/dotenv/__init__.py +0 -0
  51. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/env.py +0 -0
  52. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/frond/FROND.md +0 -0
  53. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/frond/__init__.py +0 -0
  54. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/frond/compiler.py +0 -0
  55. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/frond/parser.py +0 -0
  56. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/auth/meta.json +0 -0
  57. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  58. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/database/meta.json +0 -0
  59. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  60. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/error-overlay/meta.json +0 -0
  61. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  62. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/orm/meta.json +0 -0
  63. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  64. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  65. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/queue/meta.json +0 -0
  66. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  67. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/rest-api/meta.json +0 -0
  68. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  69. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/templates/meta.json +0 -0
  70. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  71. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  72. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/graphql/__init__.py +0 -0
  73. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/i18n/__init__.py +0 -0
  74. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/mcp/__init__.py +0 -0
  75. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/mcp/protocol.py +0 -0
  76. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/mcp/tools.py +0 -0
  77. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/messenger/__init__.py +0 -0
  78. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/migration/__init__.py +0 -0
  79. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/migration/runner.py +0 -0
  80. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/mqtt/__init__.py +0 -0
  81. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/mqtt/message.py +0 -0
  82. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/orm/__init__.py +0 -0
  83. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/orm/fields.py +0 -0
  84. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/orm/model.py +0 -0
  85. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/__feedback/widget.js +0 -0
  86. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/css/tina4.css +0 -0
  87. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/css/tina4.min.css +0 -0
  88. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/favicon.ico +0 -0
  89. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/images/logo.svg +0 -0
  90. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  91. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/js/frond.js +0 -0
  92. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/js/frond.min.js +0 -0
  93. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  94. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/js/tina4.min.js +0 -0
  95. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/js/tina4js.min.js +0 -0
  96. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/swagger/index.html +0 -0
  97. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  98. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/query_builder/__init__.py +0 -0
  99. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue/__init__.py +0 -0
  100. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue/amqp_url.py +0 -0
  101. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue/job.py +0 -0
  102. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue/kafka_backend.py +0 -0
  103. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue/lite_backend.py +0 -0
  104. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue/mongo_backend.py +0 -0
  105. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue/rabbitmq_backend.py +0 -0
  106. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue_backends/__init__.py +0 -0
  107. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue_backends/kafka_backend.py +0 -0
  108. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue_backends/mongo_backend.py +0 -0
  109. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  110. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/realtime/__init__.py +0 -0
  111. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/realtime/models/Attachment.py +0 -0
  112. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/realtime/models/Channel.py +0 -0
  113. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/realtime/models/ChannelMember.py +0 -0
  114. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/realtime/models/Message.py +0 -0
  115. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/realtime/models/Workspace.py +0 -0
  116. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/realtime/models/__init__.py +0 -0
  117. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/realtime/storage.py +0 -0
  118. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/seeder/__init__.py +0 -0
  119. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/service/__init__.py +0 -0
  120. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/session/__init__.py +0 -0
  121. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/session_handlers/__init__.py +0 -0
  122. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/session_handlers/memcached_handler.py +0 -0
  123. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  124. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/session_handlers/redis_handler.py +0 -0
  125. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/session_handlers/valkey_handler.py +0 -0
  126. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/swagger/__init__.py +0 -0
  127. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/components/crud.twig +0 -0
  128. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  129. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  130. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/docker/python/Dockerfile +0 -0
  131. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  132. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/errors/302.twig +0 -0
  133. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/errors/401.twig +0 -0
  134. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/errors/403.twig +0 -0
  135. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/errors/404.twig +0 -0
  136. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/errors/500.twig +0 -0
  137. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/errors/502.twig +0 -0
  138. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/errors/503.twig +0 -0
  139. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/errors/base.twig +0 -0
  140. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/frontend/README.md +0 -0
  141. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/templates/readme.md +0 -0
  142. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/test/__init__.py +0 -0
  143. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/test_client/__init__.py +0 -0
  144. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/validator/__init__.py +0 -0
  157. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/websocket/__init__.py +0 -0
  158. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/websocket/backplane.py +0 -0
  159. {tina4_python-3.13.99 → tina4_python-3.13.100}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.99
3
+ Version: 3.13.100
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.99"
3
+ version = "3.13.100"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.99"
54
+ return "3.13.100"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -67,14 +67,29 @@ def _skills_ref() -> str:
67
67
  return "main"
68
68
 
69
69
 
70
- def _fetch_bytes(url: str) -> "bytes | None":
70
+ def _fetch_bytes(url: str, attempts: int = 5) -> "bytes | None":
71
+ # raw.githubusercontent.com returns an intermittent 503 under load (a freshly
72
+ # cut tag is "cold" until GitHub warms its CDN), so a single failed fetch out of
73
+ # the ~30 an install makes should not abort it. Retry a transient status
74
+ # (429 / 5xx) and connection errors; a permanent 404 is never retried.
75
+ import time
71
76
  import urllib.error
72
77
  import urllib.request
73
- try:
74
- with urllib.request.urlopen(url, timeout=15) as resp:
75
- return resp.read()
76
- except (urllib.error.URLError, OSError):
77
- return None
78
+ for attempt in range(attempts):
79
+ try:
80
+ with urllib.request.urlopen(url, timeout=15) as resp:
81
+ return resp.read()
82
+ except urllib.error.HTTPError as exc:
83
+ if exc.code in (429, 500, 502, 503, 504) and attempt < attempts - 1:
84
+ time.sleep(2 * (attempt + 1))
85
+ continue
86
+ return None
87
+ except (urllib.error.URLError, OSError):
88
+ if attempt < attempts - 1:
89
+ time.sleep(2 * (attempt + 1))
90
+ continue
91
+ return None
92
+ return None
78
93
 
79
94
 
80
95
  def install_skills(root: str = ".", targets: "list[Path] | None" = None) -> list[str]:
@@ -103,6 +103,77 @@ class _LoopContext(dict):
103
103
  # found unsupported, use the interpreter) in Frond._compiled_fn.
104
104
  _COMPILE_UNSET = object()
105
105
 
106
+ # Hard cap on the TEMPLATE caches -- _compiled, _compiled_strings and
107
+ # _compiled_fn (ADR-0004, parity with PHP/Ruby/Node TEMPLATE_CACHE_MAX) --
108
+ # and on _fragment_cache (the {% cache %} tag's runtime store: a rendered
109
+ # fragment is a whole HTML string, the same order of magnitude as a compiled
110
+ # template, not a small per-expression descriptor).
111
+ #
112
+ # An entry here is a token list plus an AST plus (for _compiled_fn) an eval'd
113
+ # render closure, orders of magnitude bigger than one expression descriptor,
114
+ # so the cap sits well below MEMO_CACHE_MAX. 256 is far above any real
115
+ # application's template count, so a normal app never evicts. The cap exists
116
+ # for the workloads that genuinely grow without limit for the life of a
117
+ # worker: render_string() keys on md5(source), so an app that builds template
118
+ # strings dynamically adds an entry per distinct string; in dev the
119
+ # compiled-fn key is 'dev:' + md5(source), so every edit to a template file
120
+ # adds one; and a fragment cache keyed on a dynamic value (a page id, a user
121
+ # id) adds one entry per distinct key.
122
+ TEMPLATE_CACHE_MAX = 256
123
+
124
+ # Hard cap on every per-expression memo cache that is NOT already bounded by
125
+ # a module-level ``@lru_cache(maxsize=1024)`` (ADR-0004). ``_split_dotted``,
126
+ # ``_has_low_precedence_op``, ``_expr_descriptor`` and ``_split_on_pipe`` are
127
+ # pure functions and already use that decorator directly; ``_filter_chain_cache``
128
+ # is an INSTANCE dict (its callers are bound methods, and lru_cache on a bound
129
+ # method pins ``self`` alive forever, so it cannot use the decorator) and was
130
+ # left as a plain unbounded dict. Same number as the decorator's cap, applied
131
+ # via the manual helper below -- not a new number, the SAME one this file
132
+ # already uses four times over. Mirrors PHP's MEMO_CACHE_MAX and Ruby's
133
+ # MEMO_CACHE_MAX.
134
+ MEMO_CACHE_MAX = 1024
135
+
136
+
137
+ def _cap_cache(cache: dict, max_entries: int) -> None:
138
+ """Keep a memo cache bounded. Call immediately before inserting a new entry.
139
+
140
+ Eviction is insertion-ordered (oldest first), not true LRU: a Python dict
141
+ (3.7+) preserves insertion order, so dropping from the front is cheap,
142
+ whereas refreshing recency on every cache HIT would add writes to the
143
+ hottest path in a render and cost more than it saves. Half the cache is
144
+ dropped at once so the sweep amortises to O(1) per insert.
145
+
146
+ Evicting can never change what a render produces: every read site treats
147
+ a miss as "recompute", so a swept entry is rebuilt on next use.
148
+ """
149
+ if len(cache) < max_entries:
150
+ return
151
+ drop = max_entries // 2
152
+ for key in list(cache.keys())[:drop]:
153
+ del cache[key]
154
+
155
+
156
+ def _sweep_expired_cache(cache: dict) -> None:
157
+ """Drop every TTL-expired entry from a fragment cache: key -> (html, expires_at).
158
+
159
+ ``_cap_cache`` bounds a cache by SIZE (insertion order, oldest first) but
160
+ says nothing about STALENESS: a key that expired and is never visited
161
+ again would otherwise sit in the dict, still counted against the cap,
162
+ until something else finally evicts it. An app keying fragments on a
163
+ dynamic value (a page id, a user id) can churn through many such keys, so
164
+ staleness has to be swept on its own schedule, not just bounded by count.
165
+
166
+ Called on every ``{% cache %}`` render (cheap: bounded by
167
+ TEMPLATE_CACHE_MAX entries, so at most 256 comparisons) rather than only
168
+ for the key being read, so an unrelated key's expiry is cleaned up as a
169
+ side effect of ANY fragment-cache render, not just a future hit on that
170
+ same key.
171
+ """
172
+ now = time.time()
173
+ expired = [key for key, (_html, expires_at) in cache.items() if expires_at <= now]
174
+ for key in expired:
175
+ del cache[key]
176
+
106
177
  # ── Pre-compiled regexes for hot-path operations ───────────────
107
178
  _METHOD_CALL_RE = re.compile(r"^(\w+)\s*\((.*)?\)$", re.DOTALL)
108
179
  _FUNC_CALL_RE = re.compile(r"^([\w.]+)\s*\((.*)?\)$", re.DOTALL)
@@ -135,10 +206,39 @@ _SPACELESS_RE = re.compile(r">\s+<")
135
206
  _STRIPTAGS_RE = re.compile(r"<[^>]+>")
136
207
  _SLUG_RE = re.compile(r"[^a-z0-9]+")
137
208
  _EXTENDS_RE = re.compile(r"\{%[-\s]*extends\s+[\"'](.+?)[\"']\s*[-]?%\}")
209
+
210
+
211
+ def _extends_target(source: str) -> str:
212
+ """Return this template's OWN ``{% extends %}`` parent name, or ``""``.
213
+
214
+ A template may extend at most one parent. Before 3.13.100 a SECOND
215
+ ``{% extends %}`` tag anywhere in the source was silently invisible: only
216
+ the first (leading) occurrence was ever matched, and the rest of the
217
+ child's non-block content -- including the second extends tag -- was
218
+ already discarded the same way ordinary non-block child content is
219
+ discarded during inheritance. That hid what is almost always a mistake
220
+ (a copy-paste, a bad merge) with zero signal. Raise clearly instead, the
221
+ same policy 3.13.89 applied to an unknown tag.
222
+ """
223
+ matches = _EXTENDS_RE.findall(source)
224
+ if len(matches) > 1:
225
+ raise ValueError(
226
+ f'Frond: template has {len(matches)} "{{% extends %}}" tags -- '
227
+ "a template can extend only one parent"
228
+ )
229
+ match = _EXTENDS_RE.match(source.lstrip())
230
+ return match.group(1) if match else ""
138
231
  _BLOCK_RE = re.compile(
139
232
  r"\{%[-\s]*block\s+(\w+)\s*[-]?%\}(.*?)\{%[-\s]*endblock\s*[-]?%\}",
140
233
  re.DOTALL,
141
234
  )
235
+ # Open/close halves of _BLOCK_RE, used standalone by the depth-aware scanners
236
+ # (_extract_blocks, _substitute_blocks) that pair an open tag with its
237
+ # matching close by counting depth rather than the first endblock found --
238
+ # see _substitute_blocks for why a flat single-regex pass truncates a block
239
+ # that nests another block inside it.
240
+ _BLOCK_OPEN_RE = re.compile(r"\{%[-\s]*block\s+(\w+)\s*[-]?%\}")
241
+ _BLOCK_CLOSE_RE = re.compile(r"\{%[-\s]*endblock\s*[-]?%\}")
142
242
 
143
243
 
144
244
  # ── Ternary helpers ────────────────────────────────────────────
@@ -1690,14 +1790,13 @@ class Frond:
1690
1790
  """Register a custom filter.
1691
1791
 
1692
1792
  Callable as a classmethod (``Frond.add_filter("money", fn)``) or
1693
- as an instance method (``engine.add_filter("money", fn)``). The
1694
- filter is persisted at class level so new instances created by
1695
- hot-reload inherit it automatically; when called on an existing
1696
- instance, that instance's local filter map also receives the
1697
- addition immediately.
1793
+ as an instance method (``engine.add_filter("money", fn)``). Class
1794
+ calls are process-global; instance calls affect that engine only.
1795
+ tina4: ADR-0052.
1698
1796
  """
1699
- cls._class_filters[name] = fn
1700
- if instance is not None:
1797
+ if instance is None:
1798
+ cls._class_filters[name] = fn
1799
+ else:
1701
1800
  instance._filters[name] = fn
1702
1801
 
1703
1802
  @_ClassOrInstanceMethod
@@ -1707,8 +1806,9 @@ class Frond:
1707
1806
  Callable as classmethod or instance method. See ``add_filter`` for the
1708
1807
  dual-call semantics.
1709
1808
  """
1710
- cls._class_globals[name] = value
1711
- if instance is not None:
1809
+ if instance is None:
1810
+ cls._class_globals[name] = value
1811
+ else:
1712
1812
  instance._globals[name] = value
1713
1813
 
1714
1814
  @_ClassOrInstanceMethod
@@ -1718,8 +1818,9 @@ class Frond:
1718
1818
  Callable as classmethod or instance method. See ``add_filter`` for the
1719
1819
  dual-call semantics.
1720
1820
  """
1721
- cls._class_tests[name] = fn
1722
- if instance is not None:
1821
+ if instance is None:
1822
+ cls._class_tests[name] = fn
1823
+ else:
1723
1824
  instance._tests[name] = fn
1724
1825
 
1725
1826
  def render(self, template: str, data: dict = None) -> str:
@@ -1751,6 +1852,7 @@ class Frond:
1751
1852
  ast = parse(tokens)
1752
1853
  if not debug_mode:
1753
1854
  expires_at = (time.time() + self._cache_ttl) if self._cache_ttl > 0 else 0
1855
+ _cap_cache(self._compiled, TEMPLATE_CACHE_MAX)
1754
1856
  self._compiled[template] = (tokens, ast, expires_at)
1755
1857
  # Prod: the compiled fn is stable per template name (files don't
1756
1858
  # change under a running prod worker), so key it by name.
@@ -1773,6 +1875,7 @@ class Frond:
1773
1875
  if cached is None:
1774
1876
  tokens = _tokenize(source)
1775
1877
  cached = (tokens, parse(tokens))
1878
+ _cap_cache(self._compiled_strings, TEMPLATE_CACHE_MAX)
1776
1879
  self._compiled_strings[key] = cached
1777
1880
  return self._execute_cached(cached[0], cached[1], context, compile_key=key)
1778
1881
 
@@ -1836,6 +1939,7 @@ class Frond:
1836
1939
  return cached
1837
1940
  from tina4_python.frond.compiler import compile_template
1838
1941
  fn = compile_template(ast)
1942
+ _cap_cache(self._compiled_fn, TEMPLATE_CACHE_MAX)
1839
1943
  self._compiled_fn[compile_key] = fn
1840
1944
  return fn
1841
1945
 
@@ -1871,9 +1975,8 @@ class Frond:
1871
1975
  template: str = None, compile_key: str = None) -> str:
1872
1976
  """Execute with the source, its tokens and its AST all available."""
1873
1977
  # Handle extends first
1874
- extends_match = _EXTENDS_RE.match(source.lstrip())
1875
- if extends_match:
1876
- parent_name = extends_match.group(1)
1978
+ parent_name = _extends_target(source)
1979
+ if parent_name:
1877
1980
  parent_source = self._load(parent_name)
1878
1981
  child_blocks = self._extract_blocks(source)
1879
1982
  return self._render_with_blocks(parent_source, context, child_blocks)
@@ -1886,9 +1989,8 @@ class Frond:
1886
1989
  def _execute(self, source: str, context: dict) -> str:
1887
1990
  """Execute template source against context."""
1888
1991
  # Handle extends first
1889
- extends_match = _EXTENDS_RE.match(source.lstrip())
1890
- if extends_match:
1891
- parent_name = extends_match.group(1)
1992
+ parent_name = _extends_target(source)
1993
+ if parent_name:
1892
1994
  parent_source = self._load(parent_name)
1893
1995
 
1894
1996
  # Extract blocks from child
@@ -1906,8 +2008,8 @@ class Frond:
1906
2008
  a single non-greedy regex (which fails on nested block tags).
1907
2009
  """
1908
2010
  blocks = {}
1909
- block_open = re.compile(r"\{%[-\s]*block\s+(\w+)\s*[-]?%\}")
1910
- block_close = re.compile(r"\{%[-\s]*endblock\s*[-]?%\}")
2011
+ block_open = _BLOCK_OPEN_RE
2012
+ block_close = _BLOCK_CLOSE_RE
1911
2013
 
1912
2014
  pos = 0
1913
2015
  while pos < len(source):
@@ -1942,6 +2044,101 @@ class Frond:
1942
2044
 
1943
2045
  return blocks
1944
2046
 
2047
+ def _substitute_blocks(self, source: str, blocks: dict, context: dict) -> str:
2048
+ """Depth-aware block substitution against ``source`` (typically the
2049
+ fully-resolved root template).
2050
+
2051
+ A single non-greedy regex pass (``_BLOCK_RE.sub``) pairs an OUTER
2052
+ block's open tag with the FIRST ``{% endblock %}`` found -- which,
2053
+ when the outer block wraps a NESTED ``{% block %}``, is the nested
2054
+ block's own close tag, not the outer's. That silently truncates the
2055
+ outer block's captured content and drops everything after the inner
2056
+ endblock (the root-nested-block content-loss bug). This scans with
2057
+ an open/close depth counter instead (mirroring ``_extract_blocks``),
2058
+ so an outer block always captures its FULL body, nested child
2059
+ blocks included.
2060
+
2061
+ The content chosen for each block -- the child override in
2062
+ ``blocks`` if present, else the block's own default body -- is then
2063
+ recursively substituted against the SAME ``blocks`` map before being
2064
+ tokenized and rendered, so a block nested inside another block
2065
+ resolves correctly regardless of which template in the inheritance
2066
+ chain declared the nesting (the root, an intermediate, however many
2067
+ levels deep).
2068
+
2069
+ ``{{ parent() }}`` / ``{{ super() }}`` inside a block still render
2070
+ that block's OWN default content at this level (lazy, on first
2071
+ call) -- unaffected by the depth-aware scan.
2072
+ """
2073
+ block_open = _BLOCK_OPEN_RE
2074
+ block_close = _BLOCK_CLOSE_RE
2075
+ engine = self
2076
+ length = len(source)
2077
+ pieces = []
2078
+ pos = 0
2079
+
2080
+ while pos < length:
2081
+ m_open = block_open.search(source, pos)
2082
+ if not m_open:
2083
+ pieces.append(source[pos:])
2084
+ break
2085
+
2086
+ pieces.append(source[pos:m_open.start()]) # untouched text before the tag
2087
+
2088
+ name = m_open.group(1)
2089
+ content_start = m_open.end()
2090
+ depth = 1
2091
+ scan = content_start
2092
+ close_match = None
2093
+
2094
+ while depth > 0 and scan < length:
2095
+ next_open = block_open.search(source, scan)
2096
+ next_close = block_close.search(source, scan)
2097
+
2098
+ if next_close is None:
2099
+ break # malformed — no matching endblock
2100
+
2101
+ if next_open and next_open.start() < next_close.start():
2102
+ depth += 1
2103
+ scan = next_open.end()
2104
+ else:
2105
+ depth -= 1
2106
+ if depth == 0:
2107
+ close_match = next_close
2108
+ else:
2109
+ scan = next_close.end()
2110
+
2111
+ if close_match is None:
2112
+ # Malformed template (no matching endblock) — keep the rest
2113
+ # verbatim rather than lose it, the same leniency
2114
+ # _extract_blocks applies to this case.
2115
+ pieces.append(source[m_open.start():])
2116
+ pos = length
2117
+ break
2118
+
2119
+ parent_content = source[content_start:close_match.start()]
2120
+ block_source = blocks.get(name, parent_content)
2121
+ resolved_source = engine._substitute_blocks(block_source, blocks, context)
2122
+
2123
+ rendered_parent = None
2124
+
2125
+ def get_parent(_default=parent_content):
2126
+ nonlocal rendered_parent
2127
+ if rendered_parent is None:
2128
+ rendered_parent = SafeString(
2129
+ engine._render_tokens(_tokenize(_default), context)
2130
+ )
2131
+ return rendered_parent
2132
+
2133
+ block_ctx = dict(context)
2134
+ block_ctx["parent"] = get_parent
2135
+ block_ctx["super"] = get_parent
2136
+
2137
+ pieces.append(engine._render_tokens(_tokenize(resolved_source), block_ctx))
2138
+ pos = close_match.end()
2139
+
2140
+ return "".join(pieces)
2141
+
1945
2142
  def _render_with_blocks(self, parent_source: str, context: dict, child_blocks: dict) -> str:
1946
2143
  """Render parent template, replacing blocks with child content.
1947
2144
 
@@ -1954,9 +2151,8 @@ class Frond:
1954
2151
  the parent block's content (standard Twig/Jinja2 behavior).
1955
2152
  """
1956
2153
  # --- Multi-level extends: check if parent itself extends a grandparent ---
1957
- extends_match = _EXTENDS_RE.match(parent_source.lstrip())
1958
- if extends_match:
1959
- grandparent_name = extends_match.group(1)
2154
+ grandparent_name = _extends_target(parent_source)
2155
+ if grandparent_name:
1960
2156
  grandparent_source = self._load(grandparent_name)
1961
2157
 
1962
2158
  # Extract block defaults defined in the parent template
@@ -1984,36 +2180,10 @@ class Frond:
1984
2180
  return self._render_with_blocks(grandparent_source, context, merged_blocks)
1985
2181
 
1986
2182
  # --- Leaf parent (no extends) — resolve blocks and render ---
1987
- engine = self
1988
-
1989
- def replace_block(m):
1990
- name = m.group(1)
1991
- parent_content = m.group(2)
1992
- block_source = child_blocks.get(name, parent_content)
1993
-
1994
- # Make parent() and super() available inside child blocks
1995
- # They return the rendered parent block content
1996
- rendered_parent = None
1997
-
1998
- def get_parent():
1999
- nonlocal rendered_parent
2000
- if rendered_parent is None:
2001
- rendered_parent = SafeString(
2002
- engine._render_tokens(_tokenize(parent_content), context)
2003
- )
2004
- return rendered_parent
2005
-
2006
- # Inject parent/super into a block-local context
2007
- block_ctx = dict(context)
2008
- block_ctx["parent"] = get_parent
2009
- block_ctx["super"] = get_parent
2010
-
2011
- return engine._render_tokens(_tokenize(block_source), block_ctx)
2012
-
2013
- pattern = _BLOCK_RE
2014
-
2015
- # First pass: replace blocks
2016
- result = pattern.sub(replace_block, parent_source)
2183
+ # First pass: depth-aware block substitution (handles a block nested
2184
+ # inside another block at ANY level of the chain, including the root
2185
+ # itself — see _substitute_blocks).
2186
+ result = self._substitute_blocks(parent_source, child_blocks, context)
2017
2187
  # Second pass: render remaining tokens (text, vars outside blocks)
2018
2188
  return self._render_tokens(_tokenize(result), context)
2019
2189
 
@@ -2167,11 +2337,21 @@ class Frond:
2167
2337
  return self._eval_var_inner(expr, context)
2168
2338
 
2169
2339
  def _cached_filter_chain(self, expr: str):
2170
- """Return parsed filter chain from cache, or parse and cache."""
2340
+ """Return parsed filter chain from cache, or parse and cache.
2341
+
2342
+ ``self._filter_chain_cache`` is an INSTANCE dict (this is a bound
2343
+ method), so it cannot use the module-level ``@lru_cache(maxsize=1024)``
2344
+ the sibling pure-function parsers use (that decorator on a bound
2345
+ method pins ``self`` alive for the life of the cache). Bounded here
2346
+ with the same MEMO_CACHE_MAX via the manual cap helper instead
2347
+ (ADR-0004) — a template that builds expression strings dynamically
2348
+ must not grow this dict without limit for the life of a worker.
2349
+ """
2171
2350
  cached = self._filter_chain_cache.get(expr)
2172
2351
  if cached is not None:
2173
2352
  return cached
2174
2353
  result = _parse_filter_chain(expr)
2354
+ _cap_cache(self._filter_chain_cache, MEMO_CACHE_MAX)
2175
2355
  self._filter_chain_cache[expr] = result
2176
2356
  return result
2177
2357
 
@@ -2603,6 +2783,8 @@ class Frond:
2603
2783
  cache_key = m.group(1) if m else "default"
2604
2784
  ttl = int(m.group(2)) if m and m.group(2) else 60
2605
2785
 
2786
+ _sweep_expired_cache(self._fragment_cache)
2787
+
2606
2788
  # Check cache — a live entry means the body is never rendered
2607
2789
  cached = self._fragment_cache.get(cache_key)
2608
2790
  if cached:
@@ -2612,6 +2794,7 @@ class Frond:
2612
2794
 
2613
2795
  # Render and cache
2614
2796
  rendered = self._render_nodes(node.body, context)
2797
+ _cap_cache(self._fragment_cache, TEMPLATE_CACHE_MAX)
2615
2798
  self._fragment_cache[cache_key] = (rendered, time.time() + ttl)
2616
2799
  return rendered
2617
2800