tina4-python 3.13.0__tar.gz → 3.13.2__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 (156) hide show
  1. {tina4_python-3.13.0 → tina4_python-3.13.2}/PKG-INFO +1 -1
  2. {tina4_python-3.13.0 → tina4_python-3.13.2}/pyproject.toml +1 -1
  3. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/connection.py +3 -2
  5. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/dev_admin/__init__.py +8 -4
  6. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/orm/model.py +2 -2
  7. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/__init__.py +65 -27
  8. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/service/__init__.py +103 -0
  9. {tina4_python-3.13.0 → tina4_python-3.13.2}/.gitignore +0 -0
  10. {tina4_python-3.13.0 → tina4_python-3.13.2}/README.md +0 -0
  11. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/CLAUDE.md +0 -0
  12. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/__init__.py +0 -0
  21. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/cache.py +0 -0
  22. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/constants.py +0 -0
  23. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/events.py +0 -0
  24. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/middleware.py +0 -0
  25. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/rate_limiter.py +0 -0
  26. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/response.py +0 -0
  28. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/router.py +0 -0
  29. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/core/server.py +0 -0
  30. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/crud/__init__.py +0 -0
  31. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/__init__.py +0 -0
  32. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/adapter.py +0 -0
  33. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/firebird.py +0 -0
  34. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/mongodb.py +0 -0
  35. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/mssql.py +0 -0
  36. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/mysql.py +0 -0
  37. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/odbc.py +0 -0
  38. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/postgres.py +0 -0
  39. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/database/sqlite.py +0 -0
  40. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/debug/__init__.py +0 -0
  41. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/debug/error_overlay.py +0 -0
  42. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/dev_admin/metrics.py +0 -0
  43. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/dev_admin/plan.py +0 -0
  44. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/dev_admin/project_index.py +0 -0
  45. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/docs.py +0 -0
  46. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/dotenv/__init__.py +0 -0
  47. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/frond/FROND.md +0 -0
  48. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/frond/__init__.py +0 -0
  49. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/frond/engine.py +0 -0
  50. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/auth/meta.json +0 -0
  51. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  52. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/database/meta.json +0 -0
  53. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  54. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/error-overlay/meta.json +0 -0
  55. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  56. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/orm/meta.json +0 -0
  57. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  58. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  59. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/queue/meta.json +0 -0
  60. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  61. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/rest-api/meta.json +0 -0
  62. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  63. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/templates/meta.json +0 -0
  64. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  65. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  66. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/graphql/__init__.py +0 -0
  67. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/i18n/__init__.py +0 -0
  68. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/mcp/__init__.py +0 -0
  69. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/mcp/protocol.py +0 -0
  70. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/mcp/tools.py +0 -0
  71. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/orm/fields.py +0 -0
  76. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/__feedback/widget.js +0 -0
  77. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/css/tina4.css +0 -0
  78. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/css/tina4.min.css +0 -0
  79. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/favicon.ico +0 -0
  80. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/images/logo.svg +0 -0
  81. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  82. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/js/frond.js +0 -0
  83. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/js/frond.min.js +0 -0
  84. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  85. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  86. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/js/tina4.min.js +0 -0
  87. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/js/tina4js.min.js +0 -0
  88. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/swagger/index.html +0 -0
  89. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  90. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/query_builder/__init__.py +0 -0
  91. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue/__init__.py +0 -0
  92. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue/job.py +0 -0
  93. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue/kafka_backend.py +0 -0
  94. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue/lite_backend.py +0 -0
  95. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue/mongo_backend.py +0 -0
  96. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue/rabbitmq_backend.py +0 -0
  97. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue_backends/__init__.py +0 -0
  98. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue_backends/kafka_backend.py +0 -0
  99. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue_backends/mongo_backend.py +0 -0
  100. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  101. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/session/__init__.py +0 -0
  119. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/session_handlers/__init__.py +0 -0
  120. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  121. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/session_handlers/redis_handler.py +0 -0
  122. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/session_handlers/valkey_handler.py +0 -0
  123. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/swagger/__init__.py +0 -0
  124. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/components/crud.twig +0 -0
  125. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  126. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  127. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/docker/python/Dockerfile +0 -0
  128. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  129. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/errors/302.twig +0 -0
  130. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/errors/401.twig +0 -0
  131. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/errors/403.twig +0 -0
  132. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/errors/404.twig +0 -0
  133. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/errors/500.twig +0 -0
  134. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/errors/502.twig +0 -0
  135. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/errors/503.twig +0 -0
  136. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/errors/base.twig +0 -0
  137. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/frontend/README.md +0 -0
  138. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/templates/readme.md +0 -0
  139. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/test/__init__.py +0 -0
  140. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/test_client/__init__.py +0 -0
  141. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  142. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  143. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/validator/__init__.py +0 -0
  154. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/websocket/__init__.py +0 -0
  155. {tina4_python-3.13.0 → tina4_python-3.13.2}/tina4_python/websocket/backplane.py +0 -0
  156. {tina4_python-3.13.0 → tina4_python-3.13.2}/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.0
3
+ Version: 3.13.2
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.0"
3
+ version = "3.13.2"
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.0"
11
+ __version__ = "3.13.2"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -725,8 +725,9 @@ class Database:
725
725
  def from_env(env_key: str = "TINA4_DATABASE_URL", pool: int = 0) -> "Database | None":
726
726
  """Construct a Database instance from environment variables.
727
727
 
728
- Reads the connection URL from the named env var (default DATABASE_URL),
729
- and DATABASE_USERNAME / DATABASE_PASSWORD for credentials.
728
+ Reads the connection URL from the named env var (default
729
+ TINA4_DATABASE_URL), and TINA4_DATABASE_USERNAME /
730
+ TINA4_DATABASE_PASSWORD for credentials.
730
731
 
731
732
  Args:
732
733
  env_key: Environment variable name holding the connection URL.
@@ -1743,7 +1743,7 @@ def _tina4_robot_fallback(message: str) -> str:
1743
1743
  elif "orm" in msg or "model" in msg:
1744
1744
  return "Define ORM models in src/orm/ — one class per file. Use IntegerField, StringField, etc. Call model.save(), model.load(), model.select(). Don't forget to create a migration for the table."
1745
1745
  elif "database" in msg or "db" in msg:
1746
- return "Set DATABASE_URL in .env. Supports sqlite, postgres, mysql, firebird, mssql, mongodb. Use db.fetch(), db.insert(), db.update(), db.delete()."
1746
+ return "Set TINA4_DATABASE_URL in .env. Supports sqlite, postgres, mysql, firebird, mssql, mongodb. Use db.fetch(), db.insert(), db.update(), db.delete()."
1747
1747
  elif "queue" in msg:
1748
1748
  return "Use Queue(topic='name') with queue.produce() to enqueue, queue.consume() to process. Supports litequeue, RabbitMQ, Kafka, MongoDB backends."
1749
1749
  elif "template" in msg or "twig" in msg:
@@ -1848,14 +1848,18 @@ async def _api_connections_save(request, response):
1848
1848
  new_lines.append(line)
1849
1849
  continue
1850
1850
  key = stripped.split("=", 1)[0].strip()
1851
+ # Preserve the TINA4_ prefix when rewriting — the boot guard
1852
+ # rejects bare DATABASE_URL / DATABASE_USERNAME / DATABASE_PASSWORD
1853
+ # since v3.12, so stripping the prefix here would break the next
1854
+ # restart. Bug filed as tina4-python#45.
1851
1855
  if key == "TINA4_DATABASE_URL":
1852
- new_lines.append(f"DATABASE_URL={url}")
1856
+ new_lines.append(f"TINA4_DATABASE_URL={url}")
1853
1857
  keys_found["TINA4_DATABASE_URL"] = True
1854
1858
  elif key == "TINA4_DATABASE_USERNAME":
1855
- new_lines.append(f"DATABASE_USERNAME={username}")
1859
+ new_lines.append(f"TINA4_DATABASE_USERNAME={username}")
1856
1860
  keys_found["TINA4_DATABASE_USERNAME"] = True
1857
1861
  elif key == "TINA4_DATABASE_PASSWORD":
1858
- new_lines.append(f"DATABASE_PASSWORD={password}")
1862
+ new_lines.append(f"TINA4_DATABASE_PASSWORD={password}")
1859
1863
  keys_found["TINA4_DATABASE_PASSWORD"] = True
1860
1864
  else:
1861
1865
  new_lines.append(line)
@@ -278,7 +278,7 @@ class ORM(metaclass=ORMMeta):
278
278
  return cls._db # Direct Database instance
279
279
 
280
280
  if _database is None:
281
- # Try auto-discovery from DATABASE_URL
281
+ # Try auto-discovery from TINA4_DATABASE_URL
282
282
  import os
283
283
  url = os.environ.get("TINA4_DATABASE_URL")
284
284
  if url:
@@ -289,7 +289,7 @@ class ORM(metaclass=ORMMeta):
289
289
  orm_bind(db)
290
290
  return db
291
291
  raise RuntimeError(
292
- "No database bound. Call orm_bind(db) or set DATABASE_URL in .env"
292
+ "No database bound. Call orm_bind(db) or set TINA4_DATABASE_URL in .env"
293
293
  )
294
294
  return _database
295
295
 
@@ -249,41 +249,79 @@ def _resolve_extends(scss: str, placeholders: dict) -> str:
249
249
 
250
250
 
251
251
  def _eval_math(scss: str) -> str:
252
- """Evaluate simple math in property values (e.g., 10px + 5px)."""
252
+ """Evaluate simple math in property values (e.g., 10px + 5px).
253
+
254
+ Mixed-unit arithmetic is left verbatim — that is exactly what CSS calc()
255
+ is for, and folding it silently produces invalid output (see #42). Math
256
+ inside calc(...) is preserved untouched on the same principle: the author
257
+ asked the browser to compute it.
258
+
259
+ Rules for folding:
260
+ * Both operands unitless → fold
261
+ * Same unit on both operands → fold, keep unit
262
+ * One operand unitless for * or / → fold, keep the other unit
263
+ * Anything else (mixed units on +/-, etc) → leave verbatim
264
+ """
265
+ # Step 1 — mask calc(...) ranges so the math regex cannot eat into them.
266
+ # CSS calc() is the contract for mixed-unit arithmetic; the browser does it.
267
+ placeholders: list[str] = []
268
+
269
+ def _mask(m):
270
+ placeholders.append(m.group(0))
271
+ return f"\x00CALC{len(placeholders) - 1}\x00"
272
+
273
+ masked = re.sub(r'calc\([^()]*\)', _mask, scss)
274
+
275
+ # Step 2 — run the math fold on what remains.
253
276
  def _calc(m):
254
277
  full = m.group(0)
255
278
  try:
256
- # Extract numbers and operator
257
- num1 = float(re.search(r'[\d.]+', m.group(1)).group())
258
- num2 = float(re.search(r'[\d.]+', m.group(3)).group())
259
- op = m.group(2).strip()
260
- # Extract unit from first operand
261
- unit_match = re.search(r'[a-z%]+', m.group(1))
262
- unit = unit_match.group() if unit_match else ""
263
-
264
- if op == "+":
265
- result = num1 + num2
266
- elif op == "-":
267
- result = num1 - num2
268
- elif op == "*":
269
- result = num1 * num2
270
- elif op == "/":
271
- result = num1 / num2 if num2 != 0 else 0
272
- else:
273
- return full
274
-
275
- # Format result
276
- if result == int(result):
277
- return f"{int(result)}{unit}"
278
- return f"{result:.2f}{unit}"
279
+ num1 = float(m.group(1))
280
+ unit1 = m.group(2) or ""
281
+ op = m.group(3)
282
+ num2 = float(m.group(4))
283
+ unit2 = m.group(5) or ""
279
284
  except (ValueError, AttributeError):
280
285
  return full
281
286
 
282
- return re.sub(
283
- r'([\d.]+[a-z%]*)\s*([+\-*/])\s*([\d.]+[a-z%]*)',
284
- _calc, scss
287
+ # Decide result unit; bail (leave verbatim) if units are incompatible.
288
+ if unit1 == unit2:
289
+ unit = unit1
290
+ elif op in ("*", "/") and not unit1:
291
+ unit = unit2
292
+ elif op in ("*", "/") and not unit2:
293
+ unit = unit1
294
+ else:
295
+ return full
296
+
297
+ if op == "+":
298
+ result = num1 + num2
299
+ elif op == "-":
300
+ result = num1 - num2
301
+ elif op == "*":
302
+ result = num1 * num2
303
+ elif op == "/":
304
+ if num2 == 0:
305
+ return full
306
+ result = num1 / num2
307
+ else:
308
+ return full
309
+
310
+ if result == int(result):
311
+ return f"{int(result)}{unit}"
312
+ return f"{result:.2f}{unit}"
313
+
314
+ folded = re.sub(
315
+ r'([\d.]+)([a-z%]*)\s*([+\-*/])\s*([\d.]+)([a-z%]*)',
316
+ _calc, masked
285
317
  )
286
318
 
319
+ # Step 3 — restore the calc() ranges verbatim.
320
+ def _unmask(m):
321
+ return placeholders[int(m.group(1))]
322
+
323
+ return re.sub(r'\x00CALC(\d+)\x00', _unmask, folded)
324
+
287
325
 
288
326
  def _resolve_color_functions(scss: str, variables: dict) -> str:
289
327
  """Resolve lighten(), darken(), rgba() functions."""
@@ -177,6 +177,72 @@ class ServiceContext:
177
177
  self.log = _get_log()
178
178
 
179
179
 
180
+ # ---------------------------------------------------------------------------
181
+ # Service base class (3.13.1) — class-based pattern with run/stop/should_stop
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ class Service:
186
+ """Base class for class-based background services managed by ServiceRunner.
187
+
188
+ Cross-framework parity with PHP ``Tina4\\Service``, Ruby ``Tina4::Service``
189
+ and Node ``Tina4Service``. Subclasses implement ``run()`` (the work
190
+ loop); the base provides ``stop()`` and ``should_stop()`` helpers
191
+ backed by an internal flag.
192
+
193
+ class EmailQueueWorker(Service):
194
+ def run(self):
195
+ while not self.should_stop():
196
+ job = queue.pop()
197
+ if job is None:
198
+ time.sleep(1); continue
199
+ process(job)
200
+ job.complete()
201
+
202
+ runner = ServiceRunner()
203
+ runner.register_service("emails", EmailQueueWorker())
204
+ runner.start()
205
+
206
+ Function-style services without state can still register directly via
207
+ ``runner.register('name', callable, interval=N)``. The two patterns
208
+ coexist.
209
+ """
210
+
211
+ def __init__(self):
212
+ self._running = True
213
+
214
+ def run(self) -> None:
215
+ """Main work loop — subclasses MUST override."""
216
+ raise NotImplementedError(
217
+ f"{self.__class__.__name__}.run() must be implemented by the subclass"
218
+ )
219
+
220
+ def stop(self) -> None:
221
+ """Signal this service to stop. Override for custom shutdown behaviour
222
+ but always call ``super().stop()`` so the internal flag gets set —
223
+ ``should_stop()`` reads from it.
224
+ """
225
+ self._running = False
226
+
227
+ def should_stop(self) -> bool:
228
+ """Returns True once ``stop()`` has been called. Use inside ``run()``
229
+ loops as the exit condition::
230
+
231
+ def run(self):
232
+ while not self.should_stop():
233
+ # do work
234
+ """
235
+ return not self._running
236
+
237
+ def __call__(self, *args, **kwargs):
238
+ """Make the Service instance callable so it can be passed directly
239
+ to ``ServiceRunner.register('name', service_instance, daemon=True)``.
240
+ Cleanly dispatches to ``run()`` while ignoring the runner's
241
+ ServiceContext argument (Service subclasses manage their own state).
242
+ """
243
+ return self.run()
244
+
245
+
180
246
  # ---------------------------------------------------------------------------
181
247
  # ServiceRunner
182
248
  # ---------------------------------------------------------------------------
@@ -221,6 +287,43 @@ class ServiceRunner:
221
287
  "started_at": None,
222
288
  })
223
289
 
290
+ def register_service(self, name: str, service: "Service", max_retries: int = 3):
291
+ """Register a class-based service (:class:`Service` instance) by name.
292
+
293
+ Wraps the Service's ``run()`` method as the handler callable that
294
+ ``start()`` invokes. Defaults to ``daemon=True`` because Service
295
+ subclasses manage their own loop inside ``run()``.
296
+
297
+ class EmailWorker(Service):
298
+ def run(self):
299
+ while not self.should_stop():
300
+ process_one()
301
+
302
+ runner.register_service("emails", EmailWorker())
303
+ runner.start()
304
+
305
+ Cross-framework parity with PHP ``ServiceRunner::registerService``,
306
+ Ruby ``ServiceRunner.register_service``, Node
307
+ ``ServiceRunner.registerService``.
308
+ """
309
+ if not isinstance(service, Service):
310
+ raise TypeError(
311
+ f"service must be a Service instance, got {type(service).__name__}"
312
+ )
313
+ self.services.append({
314
+ "name": name,
315
+ "handler": service, # Service instances are callable (see __call__)
316
+ "interval": 0,
317
+ "cron": None,
318
+ "daemon": True, # Service subclasses manage their own loop
319
+ "max_retries": max_retries,
320
+ "retries": 0,
321
+ "running": False,
322
+ "last_run": None,
323
+ "started_at": None,
324
+ "instance": service, # keep reference so stop() can route to service.stop()
325
+ })
326
+
224
327
  # -- Lifecycle ----------------------------------------------------------
225
328
 
226
329
  def start(self):
File without changes
File without changes