tina4-python 3.10.90__tar.gz → 3.10.93__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 (153) hide show
  1. {tina4_python-3.10.90 → tina4_python-3.10.93}/PKG-INFO +1 -1
  2. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/Testing.py +9 -4
  3. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/api/__init__.py +2 -1
  5. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/auth/__init__.py +16 -12
  6. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/cache/__init__.py +10 -0
  7. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/cli/__init__.py +6 -6
  8. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/__init__.py +2 -2
  9. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/middleware.py +157 -76
  10. tina4_python-3.10.93/tina4_python/core/rate_limiter.py +130 -0
  11. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/request.py +14 -0
  12. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/response.py +17 -3
  13. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/router.py +65 -26
  14. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/server.py +86 -1
  15. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/crud/__init__.py +19 -1
  16. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/adapter.py +36 -0
  17. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/connection.py +103 -4
  18. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/debug/__init__.py +3 -3
  19. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/dev_admin/__init__.py +54 -0
  20. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/frond/engine.py +223 -17
  21. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/graphql/__init__.py +50 -37
  22. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/i18n/__init__.py +46 -2
  23. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/mcp/__init__.py +21 -1
  24. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/mcp/tools.py +2 -2
  25. tina4_python-3.10.93/tina4_python/migration/__init__.py +15 -0
  26. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/migration/runner.py +177 -56
  27. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/orm/model.py +65 -3
  28. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/__init__.py +92 -23
  29. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/kafka_backend.py +1 -1
  30. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/lite_backend.py +49 -4
  31. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/mongo_backend.py +1 -1
  32. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/rabbitmq_backend.py +1 -1
  33. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue_backends/__init__.py +9 -4
  34. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue_backends/kafka_backend.py +4 -1
  35. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue_backends/mongo_backend.py +4 -1
  36. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue_backends/rabbitmq_backend.py +4 -1
  37. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/__init__.py +79 -1
  38. tina4_python-3.10.93/tina4_python/seeder/__init__.py +459 -0
  39. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/service/__init__.py +66 -5
  40. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session/__init__.py +10 -6
  41. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/websocket/__init__.py +32 -13
  42. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/wsdl/__init__.py +3 -1
  43. tina4_python-3.10.90/tina4_python/migration/__init__.py +0 -13
  44. tina4_python-3.10.90/tina4_python/seeder/__init__.py +0 -182
  45. {tina4_python-3.10.90 → tina4_python-3.10.93}/.gitignore +0 -0
  46. {tina4_python-3.10.90 → tina4_python-3.10.93}/README.md +0 -0
  47. {tina4_python-3.10.90 → tina4_python-3.10.93}/pyproject.toml +0 -0
  48. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/CLAUDE.md +0 -0
  49. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/HtmlElement.py +0 -0
  50. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/ai/__init__.py +0 -0
  51. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/container/__init__.py +0 -0
  52. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/cache.py +0 -0
  53. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/constants.py +0 -0
  54. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/events.py +0 -0
  55. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/__init__.py +0 -0
  56. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/firebird.py +0 -0
  57. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/mongodb.py +0 -0
  58. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/mssql.py +0 -0
  59. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/mysql.py +0 -0
  60. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/odbc.py +0 -0
  61. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/postgres.py +0 -0
  62. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/sqlite.py +0 -0
  63. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/debug/error_overlay.py +0 -0
  64. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/dev_admin/metrics.py +0 -0
  65. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/dev_reload.py +0 -0
  66. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/dotenv/__init__.py +0 -0
  67. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/frond/FROND.md +0 -0
  68. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/frond/__init__.py +0 -0
  69. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/auth/meta.json +0 -0
  70. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  71. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/database/meta.json +0 -0
  72. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  73. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/error-overlay/meta.json +0 -0
  74. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  75. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/orm/meta.json +0 -0
  76. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  77. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  78. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/queue/meta.json +0 -0
  79. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  80. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/rest-api/meta.json +0 -0
  81. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  82. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/templates/meta.json +0 -0
  83. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  84. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  85. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/mcp/protocol.py +0 -0
  86. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/messenger/__init__.py +0 -0
  87. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/orm/__init__.py +0 -0
  88. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/orm/fields.py +0 -0
  89. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/css/tina4.css +0 -0
  90. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/css/tina4.min.css +0 -0
  91. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/favicon.ico +0 -0
  92. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/images/logo.svg +0 -0
  93. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  94. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/frond.min.js +0 -0
  95. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  96. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  97. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/tina4.min.js +0 -0
  98. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/tina4js.min.js +0 -0
  99. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/swagger/index.html +0 -0
  100. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  101. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/query_builder/__init__.py +0 -0
  102. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/job.py +0 -0
  103. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  104. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_badges.scss +0 -0
  105. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  106. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_cards.scss +0 -0
  107. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_forms.scss +0 -0
  108. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_grid.scss +0 -0
  109. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_modals.scss +0 -0
  110. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_nav.scss +0 -0
  111. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_reset.scss +0 -0
  112. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_tables.scss +0 -0
  113. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_typography.scss +0 -0
  114. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  115. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_variables.scss +0 -0
  116. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/base.scss +0 -0
  117. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/colors.scss +0 -0
  118. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/tina4.scss +0 -0
  119. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session_handlers/__init__.py +0 -0
  120. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  121. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session_handlers/redis_handler.py +0 -0
  122. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session_handlers/valkey_handler.py +0 -0
  123. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/swagger/__init__.py +0 -0
  124. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/components/crud.twig +0 -0
  125. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  126. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  127. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/docker/python/Dockerfile +0 -0
  128. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  129. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/302.twig +0 -0
  130. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/401.twig +0 -0
  131. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/403.twig +0 -0
  132. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/404.twig +0 -0
  133. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/500.twig +0 -0
  134. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/502.twig +0 -0
  135. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/503.twig +0 -0
  136. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/base.twig +0 -0
  137. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/frontend/README.md +0 -0
  138. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/readme.md +0 -0
  139. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/test_client/__init__.py +0 -0
  140. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  141. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  142. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/validator/__init__.py +0 -0
  153. {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/websocket/backplane.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.10.90
3
+ Version: 3.10.93
4
4
  Summary: Tina4 for Python — 54 built-in features, zero dependencies
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -18,8 +18,8 @@ Usage:
18
18
  return a + b
19
19
 
20
20
  Run all tests:
21
- from tina4_python.Testing import run_all_tests
22
- run_all_tests()
21
+ from tina4_python.Testing import run_all
22
+ run_all()
23
23
  """
24
24
 
25
25
  import sys
@@ -58,7 +58,7 @@ def tests(*assertions):
58
58
  """Decorator that attaches inline test assertions to a function.
59
59
 
60
60
  The decorated function is returned unchanged; the assertions are
61
- stored in a global registry and executed by ``run_all_tests()``.
61
+ stored in a global registry and executed by ``run_all()``.
62
62
  """
63
63
  def decorator(fn):
64
64
  _registry.append({
@@ -73,7 +73,7 @@ def tests(*assertions):
73
73
 
74
74
  # ── Runner ──────────────────────────────────────────────────────────
75
75
 
76
- def run_all_tests(quiet: bool = False, failfast: bool = False) -> dict:
76
+ def run_all(quiet: bool = False, failfast: bool = False) -> dict:
77
77
  """Discover and run every ``@tests``-decorated function.
78
78
 
79
79
  Returns a dict with keys ``passed``, ``failed``, ``errors``, ``details``.
@@ -171,6 +171,11 @@ def _assertion_label(assertion: dict, fn_name: str) -> str:
171
171
  return f"{fn_name} [{atype}]"
172
172
 
173
173
 
174
+ def reset():
175
+ """Reset the test registry (useful between test runs)."""
176
+ _registry.clear()
177
+
178
+
174
179
  def _print_summary(results: dict, quiet: bool):
175
180
  if quiet:
176
181
  return
@@ -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.10.90"
11
+ __version__ = "3.10.93"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -35,6 +35,7 @@ class Api:
35
35
  """Add custom headers to all requests."""
36
36
  self._headers.update(headers)
37
37
 
38
+
38
39
  def set_basic_auth(self, username: str, password: str):
39
40
  """Set Basic authentication."""
40
41
  creds = base64.b64encode(f"{username}:{password}".encode()).decode()
@@ -67,7 +68,7 @@ class Api:
67
68
  """HTTP DELETE request."""
68
69
  return self._request("DELETE", self._url(path), body)
69
70
 
70
- def send(self, method: str, path: str = "", body=None,
71
+ def send_request(self, method: str, path: str = "", body=None,
71
72
  content_type: str = "application/json") -> dict:
72
73
  """Generic request method."""
73
74
  return self._request(method.upper(), self._url(path), body, content_type)
@@ -69,14 +69,18 @@ class Auth:
69
69
  # ── JWT ────────────────────────────────────────────────────────
70
70
 
71
71
  @_DualMethod
72
- def get_token(self, payload: dict, expires_in: int = None) -> str:
72
+ def get_token(self, payload: dict, expires_in: int = None, secret: str = None) -> str:
73
73
  """Create a signed JWT token.
74
74
 
75
75
  Args:
76
76
  expires_in: Lifetime in minutes (default: self.expires_in).
77
+ secret: Override signing secret (default: self.secret / SECRET env var).
77
78
 
78
79
  Returns: header.payload.signature
79
80
  """
81
+ if secret is not None:
82
+ return Auth(secret=secret, algorithm=self.algorithm,
83
+ expires_in=self.expires_in).get_token(payload, expires_in=expires_in)
80
84
  exp_minutes = expires_in if expires_in is not None else self.expires_in
81
85
  exp_seconds = exp_minutes * 60
82
86
 
@@ -193,14 +197,6 @@ class Auth:
193
197
  auth = cls(secret=secret)
194
198
  return auth.authenticate_request(headers)
195
199
 
196
- @staticmethod
197
- def validate_api_key_static(provided: str, expected: str = None) -> bool:
198
- """Validate an API key without instantiating Auth.
199
-
200
- Alias for validate_api_key (already a staticmethod).
201
- """
202
- return Auth.validate_api_key(provided, expected)
203
-
204
200
  # ── Password Hashing ──────────────────────────────────────────
205
201
 
206
202
  @staticmethod
@@ -258,9 +254,13 @@ class Auth:
258
254
  # ── Request Auth Helper ───────────────────────────────────────
259
255
 
260
256
  @_DualMethod
261
- def authenticate_request(self, headers: dict) -> dict | None:
257
+ def authenticate_request(self, headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
262
258
  """Extract and validate auth from request headers.
263
259
 
260
+ Args:
261
+ secret: Override signing secret (default: self.secret / SECRET env var).
262
+ algorithm: JWT algorithm override (default: "HS256").
263
+
264
264
  Checks: Bearer JWT, Bearer API key, Basic auth.
265
265
  Returns payload dict on success, None on failure.
266
266
  """
@@ -300,8 +300,10 @@ def _b64url_decode(s: str) -> bytes:
300
300
 
301
301
  # ── Module-level convenience functions (use static methods) ────
302
302
 
303
- def get_token(payload: dict, expires_in: int = 60) -> str:
303
+ def get_token(payload: dict, expires_in: int = 60, secret: str = None) -> str:
304
304
  """Create a JWT — reads SECRET from env. Shortcut for Auth.get_token_static()."""
305
+ if secret is not None:
306
+ return Auth(secret=secret).get_token(payload, expires_in=expires_in)
305
307
  return Auth.get_token_static(payload, expires_in=expires_in)
306
308
 
307
309
 
@@ -320,8 +322,10 @@ def refresh_token(token: str, expires_in: int = 60) -> str | None:
320
322
  return Auth.refresh_token_static(token, expires_in=expires_in)
321
323
 
322
324
 
323
- def authenticate_request(headers: dict) -> dict | None:
325
+ def authenticate_request(headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
324
326
  """Validate auth from request headers — reads SECRET from env."""
327
+ if secret is not None:
328
+ return Auth(secret=secret).authenticate_request(headers, algorithm=algorithm)
325
329
  return Auth.authenticate_request_static(headers)
326
330
 
327
331
 
@@ -591,6 +591,11 @@ class ResponseCache:
591
591
  "backend": self._backend.name(),
592
592
  }
593
593
 
594
+ def sweep(self) -> int:
595
+ """Remove all expired entries. Returns count removed."""
596
+ self._cleanup_expired()
597
+ return 0
598
+
594
599
  def clear_cache(self) -> None:
595
600
  """Flush all cached entries and reset stats."""
596
601
  with self._lock:
@@ -709,6 +714,11 @@ def cache_delete(key: str) -> bool:
709
714
  return _get_backend().delete(key)
710
715
 
711
716
 
717
+ def sweep() -> int:
718
+ """Remove expired entries from the cache. Returns count removed."""
719
+ return _get_backend().sweep() if hasattr(_get_backend(), "sweep") else 0
720
+
721
+
712
722
  def cache_clear():
713
723
  """Clear all entries from the cache."""
714
724
  _get_backend().clear()
@@ -396,12 +396,12 @@ def _migrate(args):
396
396
  """Run pending migrations."""
397
397
  _load_env()
398
398
  from tina4_python.database import Database
399
- from tina4_python.migration import migrate
399
+ from tina4_python.migration import Migration
400
400
 
401
401
  db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
402
402
  db = Database(db_url)
403
403
  mig_dir = args[0] if args else "migrations"
404
- ran = migrate(db, mig_dir)
404
+ ran = Migration(db, mig_dir).migrate()
405
405
  if ran:
406
406
  for f in ran:
407
407
  print(f" Migrated: {f}")
@@ -426,12 +426,12 @@ def _migrate_rollback(args):
426
426
  """Rollback the last migration batch."""
427
427
  _load_env()
428
428
  from tina4_python.database import Database
429
- from tina4_python.migration import rollback
429
+ from tina4_python.migration import Migration
430
430
 
431
431
  db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
432
432
  db = Database(db_url)
433
433
  mig_dir = args[0] if args else "migrations"
434
- rolled = rollback(db, mig_dir)
434
+ rolled = Migration(db, mig_dir).rollback()
435
435
  if rolled:
436
436
  for f in rolled:
437
437
  print(f" Rolled back: {f}")
@@ -445,11 +445,11 @@ def _migrate_status(args):
445
445
  """Show migration status."""
446
446
  _load_env()
447
447
  from tina4_python.database import Database
448
- from tina4_python.migration import status
448
+ from tina4_python.migration import Migration
449
449
 
450
450
  db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
451
451
  db = Database(db_url)
452
- result = status(db, args[0] if args else "migrations")
452
+ result = Migration(db, args[0] if args else "migrations").status()
453
453
  completed, pending = result["completed"], result["pending"]
454
454
 
455
455
  if completed:
@@ -19,7 +19,7 @@ from tina4_python.core.router import (
19
19
  from tina4_python.core.middleware import CorsMiddleware, RateLimiter
20
20
  from tina4_python.core.cache import Cache
21
21
  from tina4_python.core.events import on, off, emit, emit_async, once, listeners, events, clear as clear_events
22
- from tina4_python.core.server import run, resolve_config, handle
22
+ from tina4_python.core.server import run, resolve_config, handle, start, stop
23
23
 
24
24
  __all__ = [
25
25
  "Request", "Response", "Router",
@@ -28,5 +28,5 @@ __all__ = [
28
28
  "CorsMiddleware", "RateLimiter",
29
29
  "Cache",
30
30
  "on", "off", "emit", "emit_async", "once", "listeners", "events", "clear_events",
31
- "run", "resolve_config", "handle",
31
+ "run", "resolve_config", "handle", "start", "stop",
32
32
  ]
@@ -1,32 +1,108 @@
1
- # Tina4 Middleware — CORS, Rate Limiter, CSRF.
1
+ # Tina4 Middleware — orchestrator plus built-in middleware classes.
2
2
  """
3
- Built-in middleware for cross-origin requests, rate limiting, and CSRF protection.
4
- Zero dependencies — stdlib only.
3
+ Standardized middleware orchestrator and built-in middleware.
4
+
5
+ Middleware classes follow a simple convention:
6
+ - Static methods named ``before_*`` run BEFORE the route handler
7
+ - Static methods named ``after_*`` run AFTER the route handler
8
+ - Each method receives ``(request, response)`` and returns ``(request, response)``
9
+ - If a before method sets the response status to >= 400, the chain short-circuits
5
10
 
6
- from tina4_python.core.middleware import CorsMiddleware, RateLimiter, CsrfMiddleware
11
+ Usage::
7
12
 
8
- CORS is configured via environment variables:
9
- TINA4_CORS_ORIGINS=* # Allowed origins (* = all)
10
- TINA4_CORS_METHODS=GET,POST,PUT,DELETE # Allowed methods
11
- TINA4_CORS_HEADERS=Content-Type,Authorization
12
- TINA4_CORS_MAX_AGE=86400 # Preflight cache (seconds)
13
+ from tina4_python.core.middleware import Middleware, CorsMiddleware
13
14
 
14
- Rate limiter uses a sliding window in memory:
15
- TINA4_RATE_LIMIT=100 # Requests per window
16
- TINA4_RATE_WINDOW=60 # Window in seconds
15
+ Middleware.use(CorsMiddleware)
16
+ request, response = Middleware.run_before([CorsMiddleware], request, response)
17
+ request, response = Middleware.run_after([RequestLoggerMiddleware], request, response)
17
18
 
18
- CSRF protection (off by default):
19
- TINA4_CSRF=true # Enable CSRF token validation
19
+ Zero dependencies stdlib only.
20
20
  """
21
21
  import os
22
22
  import time
23
23
  import logging
24
24
  import threading
25
25
 
26
+ from tina4_python.core.rate_limiter import RateLimiter # noqa: F401 — re-export for backward compat
27
+
28
+
29
+ class Middleware:
30
+ """Standardized middleware orchestrator.
31
+
32
+ Registers middleware classes globally and runs their ``before_*`` /
33
+ ``after_*`` static methods in alphabetical order. Mirrors the PHP,
34
+ Ruby and Node.js orchestrators.
35
+ """
36
+
37
+ _global_middleware: list = []
38
+
39
+ @classmethod
40
+ def use(cls, middleware_class) -> None:
41
+ """Register a middleware class to run on every request."""
42
+ if middleware_class not in cls._global_middleware:
43
+ cls._global_middleware.append(middleware_class)
44
+
45
+ @classmethod
46
+ def get_global(cls) -> list:
47
+ """Return the list of globally registered middleware classes."""
48
+ return list(cls._global_middleware)
49
+
50
+ @classmethod
51
+ def reset(cls) -> None:
52
+ """Clear all globally registered middleware (primarily for tests)."""
53
+ cls._global_middleware = []
54
+
55
+ @classmethod
56
+ def run_before(cls, middleware_classes, request, response):
57
+ """Run every ``before_*`` static method on the given classes.
58
+
59
+ Short-circuits if the response status becomes >= 400.
60
+ Returns ``(request, response)``.
61
+ """
62
+ for mw_class in middleware_classes:
63
+ for method_name in cls._discover_methods(mw_class, "before_"):
64
+ result = getattr(mw_class, method_name)(request, response)
65
+ if isinstance(result, tuple) and len(result) >= 2:
66
+ request, response = result[0], result[1]
67
+ status = getattr(response, "status_code", None) or getattr(response, "status", 0)
68
+ if isinstance(status, int) and status >= 400:
69
+ return request, response
70
+ return request, response
71
+
72
+ @classmethod
73
+ def run_after(cls, middleware_classes, request, response):
74
+ """Run every ``after_*`` static method on the given classes."""
75
+ for mw_class in middleware_classes:
76
+ for method_name in cls._discover_methods(mw_class, "after_"):
77
+ result = getattr(mw_class, method_name)(request, response)
78
+ if isinstance(result, tuple) and len(result) >= 2:
79
+ request, response = result[0], result[1]
80
+ return request, response
81
+
82
+ @staticmethod
83
+ def _discover_methods(mw_class, prefix: str) -> list:
84
+ """Return sorted list of public static method names with ``prefix``."""
85
+ names = [
86
+ name
87
+ for name in dir(mw_class)
88
+ if name.startswith(prefix) and callable(getattr(mw_class, name, None))
89
+ ]
90
+ return sorted(names)
91
+
26
92
 
27
93
  class CorsMiddleware:
28
94
  """CORS handler — reads config from env, injects headers."""
29
95
 
96
+ @staticmethod
97
+ def before_cors(request, response):
98
+ """Inject CORS headers on every request (class-based middleware convention)."""
99
+ instance = CorsMiddleware()
100
+ if instance.is_preflight(request):
101
+ instance.apply(request, response)
102
+ return request, response
103
+ instance.apply(request, response)
104
+ return request, response
105
+
30
106
  def __init__(self):
31
107
  self.origins = os.environ.get("TINA4_CORS_ORIGINS", "*")
32
108
  self.methods = os.environ.get(
@@ -74,73 +150,44 @@ class CorsMiddleware:
74
150
  )
75
151
 
76
152
 
77
- class RateLimiter:
78
- """Sliding window rate limiter — per-IP, in-memory.
153
+ class RateLimiterMiddleware:
154
+ """Static rate limiter middleware tracks requests per IP, returns 429 when exceeded.
79
155
 
80
- Thread-safe. Automatically cleans up expired entries.
156
+ Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
157
+ Delegates to the RateLimiter class for the actual sliding window logic.
81
158
  """
82
159
 
83
- def __init__(self):
84
- self.limit = int(os.environ.get("TINA4_RATE_LIMIT", "100"))
85
- self.window = int(os.environ.get("TINA4_RATE_WINDOW", "60"))
86
- self._requests: dict[str, list[float]] = {}
87
- self._lock = threading.Lock()
88
- self._last_cleanup = time.monotonic()
160
+ _limiter = None
161
+ _lock = threading.Lock()
89
162
 
90
- def check(self, ip: str) -> tuple[bool, dict]:
91
- """Check if request is allowed.
163
+ @classmethod
164
+ def _get_limiter(cls):
165
+ with cls._lock:
166
+ if cls._limiter is None:
167
+ cls._limiter = RateLimiter()
168
+ return cls._limiter
92
169
 
93
- Returns (allowed, info) where info has remaining/limit/reset fields.
94
- """
95
- now = time.monotonic()
96
-
97
- with self._lock:
98
- # Periodic cleanup every 60 seconds
99
- if now - self._last_cleanup > 60:
100
- self._cleanup(now)
101
- self._last_cleanup = now
102
-
103
- if ip not in self._requests:
104
- self._requests[ip] = []
105
-
106
- # Remove expired timestamps
107
- cutoff = now - self.window
108
- timestamps = self._requests[ip]
109
- self._requests[ip] = [t for t in timestamps if t > cutoff]
110
- timestamps = self._requests[ip]
111
-
112
- remaining = max(0, self.limit - len(timestamps))
113
- reset = int(self.window - (now - timestamps[0])) if timestamps else self.window
114
-
115
- if len(timestamps) >= self.limit:
116
- return False, {
117
- "limit": self.limit,
118
- "remaining": 0,
119
- "reset": reset,
120
- "window": self.window,
121
- }
122
-
123
- timestamps.append(now)
124
- return True, {
125
- "limit": self.limit,
126
- "remaining": remaining - 1,
127
- "reset": self.window,
128
- "window": self.window,
129
- }
130
-
131
- def _cleanup(self, now: float):
132
- """Remove IPs with no recent requests."""
133
- cutoff = now - self.window
134
- expired = [ip for ip, ts in self._requests.items() if not ts or ts[-1] < cutoff]
135
- for ip in expired:
136
- del self._requests[ip]
137
-
138
- def apply_headers(self, response, info: dict):
139
- """Add rate limit headers to response."""
140
- response.header("x-ratelimit-limit", str(info["limit"]))
141
- response.header("x-ratelimit-remaining", str(info["remaining"]))
142
- response.header("x-ratelimit-reset", str(info["reset"]))
143
- return response
170
+ @staticmethod
171
+ def before_rate_limit(request, response):
172
+ """Middleware hook — enforces rate limiting before the route handler."""
173
+ limiter = RateLimiterMiddleware._get_limiter()
174
+ ip = getattr(request, "ip", None) or "unknown"
175
+ allowed, info = limiter.check(ip)
176
+ limiter.apply_headers(response, info)
177
+ if not allowed:
178
+ retry_after = max(1, int(info.get("reset", limiter.window)))
179
+ response.header("retry-after", str(retry_after))
180
+ if hasattr(response, "error"):
181
+ response.error("Too Many Requests", f"Rate limit exceeded. Retry in {retry_after}s.", 429)
182
+ else:
183
+ setattr(response, "status_code", 429)
184
+ return request, response
185
+
186
+ @staticmethod
187
+ def check(ip: str):
188
+ """Check if an IP is within rate limits. Returns (allowed, info)."""
189
+ limiter = RateLimiterMiddleware._get_limiter()
190
+ return limiter.check(ip)
144
191
 
145
192
 
146
193
  class SecurityHeadersMiddleware:
@@ -308,3 +355,37 @@ class CsrfMiddleware:
308
355
  )
309
356
 
310
357
  return request, response
358
+
359
+
360
+ class RequestLoggerMiddleware:
361
+ """Request logger — stamps start time before the handler and logs elapsed time after.
362
+
363
+ Mirrors the PHP, Ruby and Node.js RequestLogger classes.
364
+ """
365
+
366
+ _logger = logging.getLogger("tina4.request")
367
+ _start_times: dict = {}
368
+ _lock = threading.Lock()
369
+
370
+ @staticmethod
371
+ def before_log(request, response):
372
+ """Record the request start time."""
373
+ key = id(request)
374
+ with RequestLoggerMiddleware._lock:
375
+ RequestLoggerMiddleware._start_times[key] = time.monotonic()
376
+ return request, response
377
+
378
+ @staticmethod
379
+ def after_log(request, response):
380
+ """Log the request method, path, status code, and elapsed time."""
381
+ key = id(request)
382
+ with RequestLoggerMiddleware._lock:
383
+ start = RequestLoggerMiddleware._start_times.pop(key, None)
384
+ elapsed_ms = round((time.monotonic() - start) * 1000, 3) if start is not None else 0.0
385
+ method = getattr(request, "method", "?")
386
+ path = getattr(request, "url", None) or getattr(request, "path", "/")
387
+ status = getattr(response, "status_code", None) or getattr(response, "status", 0)
388
+ RequestLoggerMiddleware._logger.info(
389
+ "[RequestLogger] %s %s -> %s (%sms)", method, path, status, elapsed_ms
390
+ )
391
+ return request, response
@@ -0,0 +1,130 @@
1
+ # Tina4 RateLimiter — sliding window per-IP rate limiter.
2
+ """
3
+ In-memory sliding window rate limiter.
4
+
5
+ Thread-safe. Automatically cleans up expired entries.
6
+ Reads configuration from environment variables:
7
+ TINA4_RATE_LIMIT — max requests per window (default: 100)
8
+ TINA4_RATE_WINDOW — window duration in seconds (default: 60)
9
+ """
10
+ import os
11
+ import time
12
+ import threading
13
+
14
+
15
+ class RateLimiter:
16
+ """Sliding window rate limiter — per-IP, in-memory.
17
+
18
+ Thread-safe. Automatically cleans up expired entries.
19
+ """
20
+
21
+ _shared_instance = None
22
+ _shared_lock = threading.Lock()
23
+
24
+ @classmethod
25
+ def _shared(cls) -> "RateLimiter":
26
+ with cls._shared_lock:
27
+ if cls._shared_instance is None:
28
+ cls._shared_instance = cls()
29
+ return cls._shared_instance
30
+
31
+ @staticmethod
32
+ def before_rate_limit(request, response):
33
+ """Class-based middleware entry point — enforces the shared rate limit."""
34
+ limiter = RateLimiter._shared()
35
+ ip = getattr(request, "ip", None) or "unknown"
36
+ allowed, info = limiter.check(ip)
37
+ limiter.apply_headers(response, info)
38
+ if not allowed:
39
+ retry_after = max(1, int(info.get("reset", limiter.window)))
40
+ response.header("retry-after", str(retry_after))
41
+ if hasattr(response, "error"):
42
+ response.error("Too Many Requests", f"Rate limit exceeded. Retry in {retry_after}s.", 429)
43
+ else:
44
+ setattr(response, "status_code", 429)
45
+ return request, response
46
+
47
+ def __init__(self):
48
+ self.limit = int(os.environ.get("TINA4_RATE_LIMIT", "100"))
49
+ self.window = int(os.environ.get("TINA4_RATE_WINDOW", "60"))
50
+ self._requests: dict[str, list[float]] = {}
51
+ self._lock = threading.Lock()
52
+ self._last_cleanup = time.monotonic()
53
+
54
+ def check(self, ip: str) -> tuple[bool, dict]:
55
+ """Check if request is allowed.
56
+
57
+ Returns (allowed, info) where info has remaining/limit/reset fields.
58
+ """
59
+ now = time.monotonic()
60
+
61
+ with self._lock:
62
+ # Periodic cleanup every 60 seconds
63
+ if now - self._last_cleanup > 60:
64
+ self._cleanup(now)
65
+ self._last_cleanup = now
66
+
67
+ if ip not in self._requests:
68
+ self._requests[ip] = []
69
+
70
+ # Remove expired timestamps
71
+ cutoff = now - self.window
72
+ timestamps = self._requests[ip]
73
+ self._requests[ip] = [t for t in timestamps if t > cutoff]
74
+ timestamps = self._requests[ip]
75
+
76
+ remaining = max(0, self.limit - len(timestamps))
77
+ reset = int(self.window - (now - timestamps[0])) if timestamps else self.window
78
+
79
+ if len(timestamps) >= self.limit:
80
+ return False, {
81
+ "limit": self.limit,
82
+ "remaining": 0,
83
+ "reset": reset,
84
+ "window": self.window,
85
+ }
86
+
87
+ timestamps.append(now)
88
+ return True, {
89
+ "limit": self.limit,
90
+ "remaining": remaining - 1,
91
+ "reset": self.window,
92
+ "window": self.window,
93
+ }
94
+
95
+ def _cleanup(self, now: float):
96
+ """Remove IPs with no recent requests."""
97
+ cutoff = now - self.window
98
+ expired = [ip for ip, ts in self._requests.items() if not ts or ts[-1] < cutoff]
99
+ for ip in expired:
100
+ del self._requests[ip]
101
+
102
+ def apply(self, request, response):
103
+ """Apply rate limiting to a request/response pair.
104
+
105
+ Checks the IP, sets headers, and returns 429 if over limit.
106
+ Returns (request, response).
107
+ """
108
+ ip = getattr(request, "ip", None) or "unknown"
109
+ allowed, info = self.check(ip)
110
+ self.apply_headers(response, info)
111
+ if not allowed:
112
+ retry_after = max(1, int(info.get("reset", self.window)))
113
+ response.header("retry-after", str(retry_after))
114
+ if hasattr(response, "error"):
115
+ response.error("Too Many Requests", f"Rate limit exceeded. Retry in {retry_after}s.", 429)
116
+ else:
117
+ setattr(response, "status_code", 429)
118
+ return request, response
119
+
120
+ def reset(self):
121
+ """Clear all tracked request data."""
122
+ with self._lock:
123
+ self._requests.clear()
124
+
125
+ def apply_headers(self, response, info: dict):
126
+ """Add rate limit headers to response."""
127
+ response.header("x-ratelimit-limit", str(info["limit"]))
128
+ response.header("x-ratelimit-remaining", str(info["remaining"]))
129
+ response.header("x-ratelimit-reset", str(info["reset"]))
130
+ return response
@@ -106,6 +106,20 @@ class Request:
106
106
  """Get a route parameter (from URL path). Alias for params[key]."""
107
107
  return self.params.get(key, self._route_params.get(key, default))
108
108
 
109
+ def header(self, name: str) -> str | None:
110
+ """Get a specific header value by name (case-insensitive)."""
111
+ return self.headers.get(name.lower().replace("-", "_"), self.headers.get(name.lower(), None))
112
+
113
+ def bearer_token(self) -> str | None:
114
+ """Extract the Bearer token from the Authorization header."""
115
+ auth = self.headers.get("authorization", "")
116
+ if auth.lower().startswith("bearer "):
117
+ return auth[7:]
118
+ return None
119
+
120
+ def parse_body(self) -> dict | str | None:
121
+ """Parse the raw body based on content type. Returns the parsed result."""
122
+ return _parse_body(self.raw_body, self.content_type)
109
123
 
110
124
 
111
125
  def _extract_ip(scope: dict, headers: dict) -> str: