tina4-python 3.13.45__tar.gz → 3.13.47__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 (158) hide show
  1. {tina4_python-3.13.45 → tina4_python-3.13.47}/PKG-INFO +1 -1
  2. {tina4_python-3.13.45 → tina4_python-3.13.47}/pyproject.toml +1 -1
  3. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/router.py +7 -8
  5. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/server.py +37 -6
  6. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/sqlite.py +35 -15
  7. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/migration/runner.py +126 -48
  8. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/__init__.py +24 -0
  9. {tina4_python-3.13.45 → tina4_python-3.13.47}/.gitignore +0 -0
  10. {tina4_python-3.13.45 → tina4_python-3.13.47}/README.md +0 -0
  11. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/CLAUDE.md +0 -0
  12. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/__init__.py +0 -0
  21. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/cache.py +0 -0
  22. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/constants.py +0 -0
  23. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/events.py +0 -0
  24. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/middleware.py +0 -0
  25. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/rate_limiter.py +0 -0
  26. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/core/response.py +0 -0
  28. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/crud/__init__.py +0 -0
  29. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/__init__.py +0 -0
  30. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/adapter.py +0 -0
  31. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/connection.py +0 -0
  32. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/firebird.py +0 -0
  33. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/mongodb.py +0 -0
  34. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/mssql.py +0 -0
  35. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/mysql.py +0 -0
  36. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/database/postgres.py +0 -0
  38. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/debug/__init__.py +0 -0
  39. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/debug/error_overlay.py +0 -0
  40. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/dev_admin/__init__.py +0 -0
  41. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/dev_admin/metrics.py +0 -0
  42. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/dev_admin/plan.py +0 -0
  43. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/dev_admin/project_index.py +0 -0
  44. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/docs.py +0 -0
  45. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/docstore/__init__.py +0 -0
  46. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/dotenv/__init__.py +0 -0
  47. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/env.py +0 -0
  48. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/frond/FROND.md +0 -0
  49. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/frond/__init__.py +0 -0
  50. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/frond/engine.py +0 -0
  51. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/auth/meta.json +0 -0
  52. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  53. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/database/meta.json +0 -0
  54. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  55. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/error-overlay/meta.json +0 -0
  56. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  57. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/orm/meta.json +0 -0
  58. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  59. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  60. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/queue/meta.json +0 -0
  61. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  62. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/rest-api/meta.json +0 -0
  63. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  64. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/templates/meta.json +0 -0
  65. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  66. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  67. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/graphql/__init__.py +0 -0
  68. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/i18n/__init__.py +0 -0
  69. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/mcp/__init__.py +0 -0
  70. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/mcp/protocol.py +0 -0
  71. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/mcp/tools.py +0 -0
  72. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/messenger/__init__.py +0 -0
  73. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/migration/__init__.py +0 -0
  74. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/orm/fields.py +0 -0
  76. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/orm/model.py +0 -0
  77. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/__feedback/widget.js +0 -0
  78. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/css/tina4.css +0 -0
  79. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/css/tina4.min.css +0 -0
  80. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/favicon.ico +0 -0
  81. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/images/logo.svg +0 -0
  82. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  83. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/js/frond.js +0 -0
  84. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/js/frond.min.js +0 -0
  85. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  86. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  87. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/js/tina4.min.js +0 -0
  88. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/js/tina4js.min.js +0 -0
  89. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/swagger/index.html +0 -0
  90. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  91. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/query_builder/__init__.py +0 -0
  92. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue/__init__.py +0 -0
  93. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue/job.py +0 -0
  94. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue/kafka_backend.py +0 -0
  95. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue/lite_backend.py +0 -0
  96. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue/mongo_backend.py +0 -0
  97. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue/rabbitmq_backend.py +0 -0
  98. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue_backends/__init__.py +0 -0
  99. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue_backends/kafka_backend.py +0 -0
  100. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue_backends/mongo_backend.py +0 -0
  101. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  102. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  103. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_badges.scss +0 -0
  104. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  105. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_cards.scss +0 -0
  106. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_forms.scss +0 -0
  107. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_grid.scss +0 -0
  108. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_modals.scss +0 -0
  109. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_nav.scss +0 -0
  110. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_reset.scss +0 -0
  111. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_tables.scss +0 -0
  112. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_typography.scss +0 -0
  113. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  114. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/_variables.scss +0 -0
  115. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/base.scss +0 -0
  116. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/colors.scss +0 -0
  117. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/scss/tina4css/tina4.scss +0 -0
  118. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/seeder/__init__.py +0 -0
  119. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/service/__init__.py +0 -0
  120. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/session/__init__.py +0 -0
  121. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/session_handlers/__init__.py +0 -0
  122. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  123. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/session_handlers/redis_handler.py +0 -0
  124. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/session_handlers/valkey_handler.py +0 -0
  125. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/swagger/__init__.py +0 -0
  126. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/components/crud.twig +0 -0
  127. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  128. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  129. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/docker/python/Dockerfile +0 -0
  130. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  131. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/errors/302.twig +0 -0
  132. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/errors/401.twig +0 -0
  133. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/errors/403.twig +0 -0
  134. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/errors/404.twig +0 -0
  135. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/errors/500.twig +0 -0
  136. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/errors/502.twig +0 -0
  137. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/errors/503.twig +0 -0
  138. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/errors/base.twig +0 -0
  139. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/frontend/README.md +0 -0
  140. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/templates/readme.md +0 -0
  141. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/test/__init__.py +0 -0
  142. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/test_client/__init__.py +0 -0
  143. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/validator/__init__.py +0 -0
  156. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/websocket/__init__.py +0 -0
  157. {tina4_python-3.13.45 → tina4_python-3.13.47}/tina4_python/websocket/backplane.py +0 -0
  158. {tina4_python-3.13.45 → tina4_python-3.13.47}/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.45
3
+ Version: 3.13.47
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.45"
3
+ version = "3.13.47"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.45"
11
+ __version__ = "3.13.47"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -219,18 +219,17 @@ class Router:
219
219
  """Register a global middleware class applied to every route.
220
220
 
221
221
  Equivalent to decorating every handler with @middleware(middleware_class).
222
+ Delegates to the single ``Middleware`` global registry that the request
223
+ dispatcher actually consults — mirrors PHP ``Router::use`` ->
224
+ ``Middleware::use``, Ruby ``Router.use`` -> ``Middleware.use`` and the
225
+ Node equivalent. (Before #55 this wrote to a private ``Router._global_middleware``
226
+ list that nothing read, so globals registered via ``Router.use`` never ran.)
222
227
 
223
228
  Args:
224
229
  middleware_class: A class with before_*/after_* static methods.
225
230
  """
226
- from tina4_python.core import middleware as _mw_module # avoid circular import
227
- if hasattr(_mw_module, "register_global"):
228
- _mw_module.register_global(middleware_class)
229
- else:
230
- # Fallback: store in a module-level list the dispatcher can pick up.
231
- if not hasattr(cls, "_global_middleware"):
232
- cls._global_middleware = []
233
- cls._global_middleware.append(middleware_class)
231
+ from tina4_python.core.middleware import Middleware # avoid circular import
232
+ Middleware.use(middleware_class)
234
233
 
235
234
  @classmethod
236
235
  def get(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
@@ -127,10 +127,19 @@ def _auto_discover(root_dir: str = "src"):
127
127
  importlib.import_module(module_name)
128
128
  _discovered_mtimes[module_name] = current_mtime
129
129
  Log.debug(f"Loaded: {module_name}")
130
- elif current_mtime > _discovered_mtimes.get(module_name, 0.0):
131
- # Changed module re-execute so edits to an existing route
132
- # take effect in-process. Scope guard: only ever evict a module
133
- # that lives under our discovery package. Deleting a
130
+ elif module_name not in _discovered_mtimes:
131
+ # Already in sys.modules but NEVER recorded by discovery it was
132
+ # imported TRANSITIVELY (e.g. `from src.x import y` pulled it in
133
+ # before the walk reached its file). Record its current mtime as
134
+ # the baseline WITHOUT re-importing. Re-importing here would
135
+ # del+re-add a fresh module object, while the earlier importer
136
+ # keeps the STALE one — module-level singletons would silently
137
+ # diverge (issue #53). We only ever reload a module WE loaded.
138
+ _discovered_mtimes[module_name] = current_mtime
139
+ elif current_mtime > _discovered_mtimes[module_name]:
140
+ # Changed since WE loaded it — re-execute so edits to an existing
141
+ # route take effect in-process. Scope guard: only ever evict a
142
+ # module that lives under our discovery package. Deleting a
134
143
  # tina4_python.* / third-party module would break shared
135
144
  # singletons and class identity — never do that here.
136
145
  if module_name == root_pkg or module_name.startswith(root_pkg + "."):
@@ -1322,6 +1331,28 @@ def _middleware_500(response: Response, mw_inst, method_name: str, error: Except
1322
1331
  })
1323
1332
 
1324
1333
 
1334
+ def _effective_middleware(route: dict) -> list:
1335
+ """Resolve the middleware that actually runs for a route.
1336
+
1337
+ Global middleware (registered via ``Middleware.use`` / ``Router.use``) runs
1338
+ on EVERY route, BEFORE the route's own middleware, in registration order.
1339
+ The list is deduped (by class) so a class that is both global and attached
1340
+ to the route runs once. This is the fix for issue #55 — globals were
1341
+ registered into ``Middleware._global_middleware`` but the dispatcher only
1342
+ ever iterated ``route["middleware"]``, so global middleware never ran.
1343
+ Mirrors the PHP/Ruby/Node dispatchers, which already fold globals in.
1344
+ """
1345
+ from tina4_python.core.middleware import Middleware
1346
+ resolved = []
1347
+ seen = set()
1348
+ for mw in list(Middleware.get_global()) + list(route.get("middleware", [])):
1349
+ key = mw if isinstance(mw, type) else type(mw)
1350
+ if key not in seen:
1351
+ seen.add(key)
1352
+ resolved.append(mw)
1353
+ return resolved
1354
+
1355
+
1325
1356
  def _run_before_middleware(request: Request, response: Response, route: dict) -> tuple[Request, Response, bool]:
1326
1357
  """Run class-based before_* middleware methods. Returns (request, response, skip_handler).
1327
1358
 
@@ -1338,7 +1369,7 @@ def _run_before_middleware(request: Request, response: Response, route: dict) ->
1338
1369
  before_* that sets status >= 400 also short-circuits the handler.
1339
1370
  """
1340
1371
  skip = False
1341
- for _mw_cls in route.get("middleware", []):
1372
+ for _mw_cls in _effective_middleware(route):
1342
1373
  if _is_function_middleware(_mw_cls):
1343
1374
  continue # Handled by the continuation wrapper instead
1344
1375
  _mw_inst = _mw_cls() if isinstance(_mw_cls, type) else _mw_cls
@@ -1378,7 +1409,7 @@ def _run_after_middleware(request: Request, response: Response, route: dict) ->
1378
1409
  Resilience (M2): each after_* call is wrapped — a method that THROWS is
1379
1410
  logged and converted to a clean 500; remaining after_* still run.
1380
1411
  """
1381
- for _mw_cls in route.get("middleware", []):
1412
+ for _mw_cls in _effective_middleware(route):
1382
1413
  if _is_function_middleware(_mw_cls):
1383
1414
  continue
1384
1415
  _mw_inst = _mw_cls() if isinstance(_mw_cls, type) else _mw_cls
@@ -93,23 +93,43 @@ class SQLiteAdapter(DatabaseAdapter):
93
93
  )
94
94
 
95
95
  def execute_many(self, sql: str, params_list: list[list] = None) -> DatabaseResult:
96
- """Optimized batch execute using SQLite's executemany."""
96
+ """Optimized batch execute using SQLite's executemany — ATOMIC (one txn).
97
+
98
+ The batch runs inside one transaction so a bad row mid-batch rolls the
99
+ WHOLE batch back (all-or-nothing) instead of leaving the rows applied
100
+ before the failure in an open, uncommitted transaction. Mirrors the base
101
+ DatabaseAdapter.execute_many owns-transaction guard and the other engines.
102
+ """
97
103
  sql = self._translate_sql(sql)
98
104
  rows = params_list or []
99
- self._conn.executemany(sql, rows)
100
-
101
- # cursor.rowcount / cursor.lastrowid are unreliable after executemany()
102
- # in sqlite3 (rowcount can come back 0 or -1, lastrowid is not set) and
103
- # were non-deterministic across pooled connections. The batch is
104
- # all-or-raise, so every supplied row was applied; the last inserted id
105
- # is read deterministically from last_insert_rowid() on this connection.
106
- affected = len(rows)
107
- last_row = self._conn.execute("SELECT last_insert_rowid()").fetchone()
108
- last_id = last_row[0] if last_row and last_row[0] else None
109
-
110
- if not self._in_transaction and self.autocommit:
111
- if self._conn.in_transaction:
112
- self._conn.execute("COMMIT")
105
+
106
+ # Own (and commit/rollback) the batch transaction only for a standalone
107
+ # write in autocommit mode never inside a caller's explicit transaction.
108
+ standalone = self.autocommit and not self._in_transaction
109
+ if standalone and not self._conn.in_transaction:
110
+ self._conn.execute("BEGIN")
111
+
112
+ try:
113
+ self._conn.executemany(sql, rows)
114
+ # cursor.rowcount / cursor.lastrowid are unreliable after executemany()
115
+ # in sqlite3 (rowcount can come back 0 or -1, lastrowid is not set).
116
+ # The batch is all-or-nothing, so every supplied row was applied; read
117
+ # the last inserted id deterministically from last_insert_rowid().
118
+ affected = len(rows)
119
+ last_row = self._conn.execute("SELECT last_insert_rowid()").fetchone()
120
+ last_id = last_row[0] if last_row and last_row[0] else None
121
+ except Exception:
122
+ # Roll the partial batch back so nothing is left half-applied, then
123
+ # FAIL LOUD (re-raise) — parity with execute() and the other engines.
124
+ if standalone and self._conn.in_transaction:
125
+ try:
126
+ self._conn.execute("ROLLBACK")
127
+ except Exception:
128
+ pass
129
+ raise
130
+
131
+ if standalone and self._conn.in_transaction:
132
+ self._conn.execute("COMMIT")
113
133
 
114
134
  return DatabaseResult(
115
135
  affected_rows=affected,
@@ -332,59 +332,137 @@ def _normalize_quotes(sql: str) -> str:
332
332
 
333
333
 
334
334
  def _split_statements(sql: str, delimiter: str = ";") -> list[str]:
335
- """Split SQL into individual statements.
336
-
337
- Handles:
338
- - Line comments (-- ...)
339
- - Block comments (/* ... */)
340
- - Empty statements
341
- - Stored procedure blocks delimited by $$ or //
342
- Example:
343
- CREATE TRIGGER foo $$ BEGIN ... END $$;
344
- CREATE PROCEDURE bar // BEGIN ... END //;
335
+ """Split SQL into individual statements with a single-pass, quote- and
336
+ comment-aware scanner.
337
+
338
+ The split decision is made character by character so the delimiter only ever
339
+ fires in real statement position. This is the fix for issue #54: the old
340
+ implementation split on ``delimiter`` BEFORE stripping ``-- …`` line comments,
341
+ so a ``;`` *inside* a line comment fragmented one statement into several
342
+ broken pieces. A scanner that knows where it is — code, comment, or string —
343
+ cannot make that mistake.
344
+
345
+ Handled, in priority order, only when NOT already inside a stored-proc block:
346
+
347
+ - **Stored-procedure blocks** delimited by ``$$`` or ``//`` are kept intact
348
+ (their inner ``;`` never splits). A ``//`` preceded by ``:`` is a URL
349
+ scheme (``https://…``), not a block delimiter, so it is left alone.
350
+ - **Block comments** ``/* … */`` are stripped.
351
+ - **Line comments** ``-- …`` are stripped to end of line (the newline is
352
+ kept, so line structure survives). A ``;`` here never splits.
353
+ - **String literals** — single-quoted ``'…'`` and double-quoted identifiers
354
+ ``"…"`` — are copied verbatim, honouring the SQL doubled-quote escape
355
+ (``''`` / ``""``). A ``;``, ``--`` or ``/*`` inside a literal is data, not
356
+ a delimiter or comment, so it is preserved untouched.
357
+ - The **delimiter** ends a statement only when reached outside all of the
358
+ above. Empty statements are dropped; each kept statement is trimmed.
359
+
360
+ Smart/curly quotes are normalized to straight ASCII first. Mirrors the
361
+ tina4-php ``Migration::splitStatements`` scanner for cross-framework parity.
345
362
  """
346
363
  # Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
347
364
  # an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs.
348
365
  sql = _normalize_quotes(sql)
349
366
 
350
- # Extract blocks delimited by $$ or // first, replacing them with placeholders
351
- blocks: list[str] = []
352
-
353
- def _save_block(m):
354
- blocks.append(m.group(0))
355
- return f"__BLOCK_{len(blocks) - 1}__"
356
-
357
- # Match $$ ... $$ or // ... // blocks (stored procedures, triggers, etc.).
358
- # The `//` delimiters must NOT be preceded by a colon, so a URL scheme
359
- # (`https://…`) or other `://` literal inside a migration is never captured
360
- # as an opaque stored-proc block (it would otherwise swallow everything
361
- # between two `//` occurrences and skip statement splitting/cleaning).
362
- processed = re.sub(r"\$\$(.*?)\$\$", _save_block, sql, flags=re.DOTALL)
363
- processed = re.sub(r"(?<!:)//(.*?)(?<!:)//", _save_block, processed, flags=re.DOTALL)
364
-
365
- # Remove block comments (but not inside stored proc blocks)
366
- clean = re.sub(r"/\*.*?\*/", "", processed, flags=re.DOTALL)
367
-
368
- statements = []
369
- for stmt in clean.split(delimiter):
370
- # Remove line comments and blank lines
371
- lines = []
372
- for line in stmt.split("\n"):
373
- stripped = line.strip()
374
- if stripped and not stripped.startswith("--"):
375
- # Remove inline comments (-- after SQL)
376
- comment_pos = line.find("--")
377
- if comment_pos >= 0:
378
- line = line[:comment_pos]
379
- lines.append(line)
380
- cleaned = "\n".join(lines).strip()
381
-
382
- # Restore block placeholders
383
- for i, block in enumerate(blocks):
384
- cleaned = cleaned.replace(f"__BLOCK_{i}__", block)
385
-
386
- if cleaned:
387
- statements.append(cleaned)
367
+ statements: list[str] = []
368
+ current: list[str] = []
369
+ n = len(sql)
370
+ dlen = len(delimiter)
371
+ i = 0
372
+ in_dollar_block = False
373
+ in_slash_block = False
374
+
375
+ while i < n:
376
+ ch = sql[i]
377
+
378
+ # $$ $$ stored-proc block (toggle in/out).
379
+ if not in_slash_block and ch == "$" and i + 1 < n and sql[i + 1] == "$":
380
+ current.append("$$")
381
+ i += 2
382
+ in_dollar_block = not in_dollar_block
383
+ continue
384
+
385
+ # // … // stored-proc block (toggle). The `//` must NOT be preceded by a
386
+ # colon, so a URL scheme (`https://…`) or any `://` literal is never
387
+ # treated as a block delimiter (it would otherwise swallow everything
388
+ # between two `//` occurrences and skip statement splitting).
389
+ if (not in_dollar_block and ch == "/" and i + 1 < n and sql[i + 1] == "/"
390
+ and not (i > 0 and sql[i - 1] == ":")):
391
+ current.append("//")
392
+ i += 2
393
+ in_slash_block = not in_slash_block
394
+ continue
395
+
396
+ # Inside a stored-proc block: consume verbatim (inner ; never splits).
397
+ if in_dollar_block or in_slash_block:
398
+ current.append(ch)
399
+ i += 1
400
+ continue
401
+
402
+ # Block comment /* … */ — stripped.
403
+ if ch == "/" and i + 1 < n and sql[i + 1] == "*":
404
+ end = sql.find("*/", i + 2)
405
+ i = (end + 2) if end != -1 else n
406
+ continue
407
+
408
+ # Line comment -- … — stripped to end of line; the newline is left for
409
+ # the next iteration so line structure (and statement boundaries on the
410
+ # NEXT line) survive. A ';' inside the comment is NOT a delimiter.
411
+ if ch == "-" and i + 1 < n and sql[i + 1] == "-":
412
+ end = sql.find("\n", i + 2)
413
+ i = end if end != -1 else n
414
+ continue
415
+
416
+ # Single-quoted string literal — '' escapes a quote. Copied verbatim so a
417
+ # ';' / '--' / '/*' inside the value is data, not a delimiter/comment.
418
+ if ch == "'":
419
+ current.append("'")
420
+ i += 1
421
+ while i < n:
422
+ if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
423
+ current.append("''")
424
+ i += 2
425
+ elif sql[i] == "'":
426
+ current.append("'")
427
+ i += 1
428
+ break
429
+ else:
430
+ current.append(sql[i])
431
+ i += 1
432
+ continue
433
+
434
+ # Double-quoted identifier — "" escapes a quote. Same verbatim handling.
435
+ if ch == '"':
436
+ current.append('"')
437
+ i += 1
438
+ while i < n:
439
+ if sql[i] == '"' and i + 1 < n and sql[i + 1] == '"':
440
+ current.append('""')
441
+ i += 2
442
+ elif sql[i] == '"':
443
+ current.append('"')
444
+ i += 1
445
+ break
446
+ else:
447
+ current.append(sql[i])
448
+ i += 1
449
+ continue
450
+
451
+ # Statement delimiter — only reached outside blocks/comments/strings.
452
+ if delimiter and sql.startswith(delimiter, i):
453
+ stmt = "".join(current).strip()
454
+ if stmt:
455
+ statements.append(stmt)
456
+ current = []
457
+ i += dlen
458
+ continue
459
+
460
+ current.append(ch)
461
+ i += 1
462
+
463
+ stmt = "".join(current).strip()
464
+ if stmt:
465
+ statements.append(stmt)
388
466
  return statements
389
467
 
390
468
 
@@ -131,6 +131,9 @@ def _compile(scss: str) -> str:
131
131
  # 6. Resolve @extend
132
132
  scss = _resolve_extends(scss, placeholders)
133
133
 
134
+ # 6.5. Resolve #{ ... } interpolation (before $var substitution + nesting).
135
+ scss = _resolve_interpolation(scss, variables)
136
+
134
137
  # 7. Substitute variables
135
138
  scss = _substitute_variables(scss, variables)
136
139
 
@@ -171,6 +174,27 @@ def _substitute_variables(scss: str, variables: dict) -> str:
171
174
  return scss
172
175
 
173
176
 
177
+ def _resolve_interpolation(scss: str, variables: dict) -> str:
178
+ """Resolve SCSS ``#{ ... }`` interpolation.
179
+
180
+ Each ``#{ expr }`` is replaced by its resolved inner text: a ``$variable``
181
+ inside the braces resolves to its value, anything else is inlined verbatim
182
+ (trimmed). This lets a value carry a variable inside a string context the
183
+ plain ``$var`` substitution can't reach — e.g. ``calc(100% - #{$gap})`` ->
184
+ ``calc(100% - 20px)`` — and lets a variable appear in a selector
185
+ (``.icon-#{$name}`` -> ``.icon-home``). Run BEFORE nested-rule flattening so
186
+ the literal ``{``/``}`` never confuse the block matcher. The inner cannot
187
+ contain braces (interpolation is a leaf expression), so ``[^{}]*`` is safe.
188
+ """
189
+ def _resolve(m):
190
+ inner = m.group(1).strip()
191
+ for name in sorted(variables.keys(), key=len, reverse=True):
192
+ inner = inner.replace(f"${name}", variables[name])
193
+ return inner
194
+
195
+ return re.sub(r'#\{([^{}]*)\}', _resolve, scss)
196
+
197
+
174
198
  def _extract_mixins(scss: str, mixins: dict) -> str:
175
199
  """Extract @mixin definitions."""
176
200
  pattern = re.compile(
File without changes