tina4-python 3.8.0__tar.gz → 3.8.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 (137) hide show
  1. {tina4_python-3.8.0 → tina4_python-3.8.2}/.gitignore +5 -0
  2. {tina4_python-3.8.0 → tina4_python-3.8.2}/PKG-INFO +1 -1
  3. {tina4_python-3.8.0 → tina4_python-3.8.2}/pyproject.toml +1 -1
  4. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/middleware.py +48 -0
  6. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/request.py +17 -0
  7. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/response.py +22 -0
  8. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/connection.py +156 -27
  9. tina4_python-3.8.2/tina4_python/test_client/__init__.py +177 -0
  10. tina4_python-3.8.2/tina4_python/validator/__init__.py +169 -0
  11. {tina4_python-3.8.0 → tina4_python-3.8.2}/README.md +0 -0
  12. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/CLAUDE.md +0 -0
  13. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/HtmlElement.py +0 -0
  14. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/Testing.py +0 -0
  15. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/ai/__init__.py +0 -0
  16. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/api/__init__.py +0 -0
  17. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/auth/__init__.py +0 -0
  18. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/cache/__init__.py +0 -0
  19. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/cli/__init__.py +0 -0
  20. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/container/__init__.py +0 -0
  21. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/__init__.py +0 -0
  22. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/cache.py +0 -0
  23. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/constants.py +0 -0
  24. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/events.py +0 -0
  25. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/router.py +0 -0
  26. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/server.py +0 -0
  27. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/crud/__init__.py +0 -0
  28. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/__init__.py +0 -0
  29. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/adapter.py +0 -0
  30. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/firebird.py +0 -0
  31. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/mssql.py +0 -0
  32. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/mysql.py +0 -0
  33. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/odbc.py +0 -0
  34. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/postgres.py +0 -0
  35. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/sqlite.py +0 -0
  36. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/debug/__init__.py +0 -0
  37. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/dev_reload.py +0 -0
  40. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/dotenv/__init__.py +0 -0
  41. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/frond/FROND.md +0 -0
  42. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/frond/__init__.py +0 -0
  43. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/frond/engine.py +0 -0
  44. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/auth/meta.json +0 -0
  45. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  46. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/database/meta.json +0 -0
  47. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  48. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/error-overlay/meta.json +0 -0
  49. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  50. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/orm/meta.json +0 -0
  51. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  52. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  53. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/queue/meta.json +0 -0
  54. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  55. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/rest-api/meta.json +0 -0
  56. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  57. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/templates/meta.json +0 -0
  58. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  59. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  60. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/graphql/__init__.py +0 -0
  61. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/i18n/__init__.py +0 -0
  62. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/messenger/__init__.py +0 -0
  63. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/migration/__init__.py +0 -0
  64. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/migration/runner.py +0 -0
  65. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/orm/__init__.py +0 -0
  66. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/orm/fields.py +0 -0
  67. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/orm/model.py +0 -0
  68. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/css/tina4.css +0 -0
  69. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/css/tina4.min.css +0 -0
  70. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/favicon.ico +0 -0
  71. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/images/logo.svg +0 -0
  72. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  73. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/js/frond.min.js +0 -0
  74. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  75. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/js/tina4.min.js +0 -0
  76. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/js/tina4js.min.js +0 -0
  77. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/swagger/index.html +0 -0
  78. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  79. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue/__init__.py +0 -0
  80. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue_backends/__init__.py +0 -0
  81. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue_backends/kafka_backend.py +0 -0
  82. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue_backends/mongo_backend.py +0 -0
  83. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  84. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/__init__.py +0 -0
  85. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  86. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_badges.scss +0 -0
  87. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  88. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_cards.scss +0 -0
  89. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_forms.scss +0 -0
  90. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_grid.scss +0 -0
  91. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_modals.scss +0 -0
  92. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_nav.scss +0 -0
  93. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_reset.scss +0 -0
  94. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_tables.scss +0 -0
  95. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_typography.scss +0 -0
  96. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  97. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_variables.scss +0 -0
  98. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/base.scss +0 -0
  99. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/colors.scss +0 -0
  100. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/tina4.scss +0 -0
  101. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/seeder/__init__.py +0 -0
  102. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/service/__init__.py +0 -0
  103. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session/__init__.py +0 -0
  104. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session_handlers/__init__.py +0 -0
  105. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  106. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session_handlers/redis_handler.py +0 -0
  107. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session_handlers/valkey_handler.py +0 -0
  108. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/swagger/__init__.py +0 -0
  109. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/components/crud.twig +0 -0
  110. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  111. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  112. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/docker/python/Dockerfile +0 -0
  113. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  114. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/302.twig +0 -0
  115. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/401.twig +0 -0
  116. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/403.twig +0 -0
  117. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/404.twig +0 -0
  118. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/500.twig +0 -0
  119. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/502.twig +0 -0
  120. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/503.twig +0 -0
  121. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/base.twig +0 -0
  122. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/frontend/README.md +0 -0
  123. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/readme.md +0 -0
  124. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  125. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  126. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  127. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  128. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  129. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  130. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  131. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  132. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  133. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  134. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  135. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  136. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/websocket/__init__.py +0 -0
  137. {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/wsdl/__init__.py +0 -0
@@ -58,3 +58,8 @@ __pycache__/
58
58
  *.c
59
59
  *.so
60
60
  build/
61
+ .pytest_cache/
62
+ demo/.env
63
+ example/.env
64
+ demo/data/*.db
65
+ demo/logs/*.log
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.8.0
3
+ Version: 3.8.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.8.0"
3
+ version = "3.8.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.8.0"
11
+ __version__ = "3.8.2"
12
12
 
13
13
  # ── HTTP Constants ──
14
14
  from tina4_python.core.constants import ( # noqa: E402, F401
@@ -137,3 +137,51 @@ class RateLimiter:
137
137
  response.header("x-ratelimit-remaining", str(info["remaining"]))
138
138
  response.header("x-ratelimit-reset", str(info["reset"]))
139
139
  return response
140
+
141
+
142
+ class SecurityHeadersMiddleware:
143
+ """Injects security headers on every response.
144
+
145
+ Configurable via environment variables:
146
+ TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
147
+ TINA4_HSTS — Strict-Transport-Security max-age value
148
+ (default: "" = off; set to "31536000" to enable)
149
+ TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
150
+ TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
151
+ TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
152
+ """
153
+
154
+ @staticmethod
155
+ def before_security(request, response):
156
+ """Set security headers before the route handler runs."""
157
+ response.header(
158
+ "x-frame-options",
159
+ os.environ.get("TINA4_FRAME_OPTIONS", "SAMEORIGIN"),
160
+ )
161
+ response.header("x-content-type-options", "nosniff")
162
+
163
+ hsts = os.environ.get("TINA4_HSTS", "")
164
+ if hsts:
165
+ response.header(
166
+ "strict-transport-security",
167
+ f"max-age={hsts}; includeSubDomains",
168
+ )
169
+
170
+ response.header(
171
+ "content-security-policy",
172
+ os.environ.get("TINA4_CSP", "default-src 'self'"),
173
+ )
174
+ response.header(
175
+ "referrer-policy",
176
+ os.environ.get("TINA4_REFERRER_POLICY", "strict-origin-when-cross-origin"),
177
+ )
178
+ response.header("x-xss-protection", "0")
179
+ response.header(
180
+ "permissions-policy",
181
+ os.environ.get(
182
+ "TINA4_PERMISSIONS_POLICY",
183
+ "camera=(), microphone=(), geolocation=()",
184
+ ),
185
+ )
186
+
187
+ return request, response
@@ -3,8 +3,17 @@
3
3
  Clean request object with parsed body, params, headers, and cookies.
4
4
  """
5
5
  import json
6
+ import os
6
7
  from urllib.parse import parse_qs, unquote
7
8
 
9
+ # Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.
10
+ TINA4_MAX_UPLOAD_SIZE = int(os.environ.get("TINA4_MAX_UPLOAD_SIZE", 10_485_760))
11
+
12
+
13
+ class PayloadTooLarge(Exception):
14
+ """Raised when request body exceeds TINA4_MAX_UPLOAD_SIZE."""
15
+ pass
16
+
8
17
 
9
18
  class Request:
10
19
  """Parsed HTTP request — everything a route handler needs."""
@@ -46,6 +55,14 @@ class Request:
46
55
  req.content_type = req.headers.get("content-type", "")
47
56
  req.ip = _extract_ip(scope, req.headers)
48
57
 
58
+ # Check upload size limit
59
+ content_length = int(req.headers.get("content-length", 0) or 0)
60
+ if content_length > TINA4_MAX_UPLOAD_SIZE or len(body) > TINA4_MAX_UPLOAD_SIZE:
61
+ raise PayloadTooLarge(
62
+ f"Request body ({max(content_length, len(body))} bytes) exceeds "
63
+ f"TINA4_MAX_UPLOAD_SIZE ({TINA4_MAX_UPLOAD_SIZE} bytes)"
64
+ )
65
+
49
66
  # Parse query params
50
67
  if req.query_string:
51
68
  parsed = parse_qs(req.query_string, keep_blank_values=True)
@@ -132,6 +132,14 @@ class Response:
132
132
  self.content = content.encode() if isinstance(content, str) else content
133
133
  return self
134
134
 
135
+ def error(self, code: str, message: str, status_code: int = 400) -> "Response":
136
+ """Standard error response envelope.
137
+
138
+ Usage:
139
+ return response.error("VALIDATION_FAILED", "Email is required", 400)
140
+ """
141
+ return self.json(error_response(code, message, status_code), status_code)
142
+
135
143
  def xml(self, content: str, status_code: int = None) -> "Response":
136
144
  """XML response."""
137
145
  if status_code:
@@ -229,6 +237,20 @@ class Response:
229
237
  return headers
230
238
 
231
239
 
240
+ def error_response(code: str, message: str, status: int = 400) -> dict:
241
+ """Build a standard error response envelope.
242
+
243
+ Usage:
244
+ return response(error_response("VALIDATION_FAILED", "Email is required", 400), 400)
245
+ """
246
+ return {
247
+ "error": True,
248
+ "code": code,
249
+ "message": message,
250
+ "status": status,
251
+ }
252
+
253
+
232
254
  def _is_compressible(content_type: str) -> bool:
233
255
  """Check if content type benefits from compression."""
234
256
  compressible = (
@@ -5,6 +5,9 @@ The Database class parses a connection URL and creates the right adapter.
5
5
  db = Database("sqlite:///data/app.db")
6
6
  db = Database("postgresql://user:pass@host:5432/dbname")
7
7
  db = Database() # Reads DATABASE_URL from environment
8
+
9
+ Connection pooling:
10
+ db = Database("sqlite:///data/app.db", pool=4) # 4 connections, round-robin
8
11
  """
9
12
  import hashlib
10
13
  import os
@@ -14,6 +17,73 @@ from urllib.parse import urlparse
14
17
  from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult
15
18
 
16
19
 
20
+ class ConnectionPool:
21
+ """Thread-safe connection pool using round-robin rotation.
22
+
23
+ When pool_size > 0, maintains multiple adapter instances and rotates
24
+ through them for each operation. Connections are created lazily on
25
+ first use.
26
+
27
+ Usage:
28
+ pool = ConnectionPool(pool_size=4, factory=create_adapter,
29
+ connect_args=("path", {"username": "u", "password": "p"}))
30
+ adapter = pool.checkout()
31
+ try:
32
+ result = adapter.fetch(sql, params, limit, offset)
33
+ finally:
34
+ pool.checkin(adapter)
35
+ pool.close_all()
36
+ """
37
+
38
+ def __init__(self, pool_size: int, factory: callable, connect_path: str,
39
+ username: str = "", password: str = ""):
40
+ self._pool_size = pool_size
41
+ self._factory = factory
42
+ self._connect_path = connect_path
43
+ self._username = username
44
+ self._password = password
45
+ self._adapters: list[DatabaseAdapter | None] = [None] * pool_size
46
+ self._index = 0
47
+ self._lock = threading.Lock()
48
+
49
+ def _ensure_adapter(self, idx: int) -> DatabaseAdapter:
50
+ """Lazily create an adapter at the given index."""
51
+ if self._adapters[idx] is None:
52
+ adapter = self._factory()
53
+ adapter.connect(self._connect_path, username=self._username, password=self._password)
54
+ self._adapters[idx] = adapter
55
+ return self._adapters[idx]
56
+
57
+ def checkout(self) -> DatabaseAdapter:
58
+ """Get the next adapter via round-robin. Thread-safe."""
59
+ with self._lock:
60
+ idx = self._index
61
+ self._index = (self._index + 1) % self._pool_size
62
+ return self._ensure_adapter(idx)
63
+
64
+ def checkin(self, adapter: DatabaseAdapter) -> None:
65
+ """Return an adapter to the pool. Currently a no-op for round-robin."""
66
+ pass
67
+
68
+ def close_all(self) -> None:
69
+ """Close all active connections in the pool."""
70
+ with self._lock:
71
+ for i, adapter in enumerate(self._adapters):
72
+ if adapter is not None:
73
+ adapter.close()
74
+ self._adapters[i] = None
75
+
76
+ @property
77
+ def size(self) -> int:
78
+ return self._pool_size
79
+
80
+ @property
81
+ def active_count(self) -> int:
82
+ """Number of connections that have been created."""
83
+ with self._lock:
84
+ return sum(1 for a in self._adapters if a is not None)
85
+
86
+
17
87
  # Driver registry — maps URL scheme to adapter class
18
88
  _DRIVERS: dict[str, type] = {}
19
89
 
@@ -65,9 +135,23 @@ class Database:
65
135
  # Priority: constructor params > env vars > empty
66
136
  self.username = username or os.environ.get("DATABASE_USERNAME", "")
67
137
  self.password = password or os.environ.get("DATABASE_PASSWORD", "")
68
- self.pool_size = pool # Reserved for future connection pooling (0 = single connection)
69
- self._adapter: DatabaseAdapter = self._create_adapter()
70
- self._adapter.connect(self._connection_path(), username=self.username, password=self.password)
138
+ self.pool_size = pool # 0 = single connection, N>0 = N pooled connections
139
+
140
+ if self.pool_size > 0:
141
+ # Pooled mode — create a ConnectionPool with lazy adapter creation
142
+ self._pool = ConnectionPool(
143
+ pool_size=self.pool_size,
144
+ factory=self._create_adapter,
145
+ connect_path=self._connection_path(),
146
+ username=self.username,
147
+ password=self.password,
148
+ )
149
+ self._adapter: DatabaseAdapter | None = None
150
+ else:
151
+ # Single-connection mode — current behavior
152
+ self._pool: ConnectionPool | None = None
153
+ self._adapter: DatabaseAdapter = self._create_adapter()
154
+ self._adapter.connect(self._connection_path(), username=self.username, password=self.password)
71
155
 
72
156
  # Query cache — off by default, opt-in via TINA4_DB_CACHE=true
73
157
  from tina4_python.dotenv import is_truthy
@@ -166,20 +250,34 @@ class Database:
166
250
  self._cache_hits = 0
167
251
  self._cache_misses = 0
168
252
 
253
+ # ── Pool-aware adapter access ─────────────────────────────
254
+
255
+ def _get_adapter(self) -> DatabaseAdapter:
256
+ """Get an adapter — from pool (round-robin) or single connection."""
257
+ if self._pool is not None:
258
+ return self._pool.checkout()
259
+ return self._adapter
260
+
169
261
  # ── Delegate to adapter — with cache integration ─────────
170
262
 
171
263
  def close(self):
172
- self._adapter.close()
264
+ """Close all connections (pool or single)."""
265
+ if self._pool is not None:
266
+ self._pool.close_all()
267
+ elif self._adapter is not None:
268
+ self._adapter.close()
173
269
 
174
270
  def execute(self, sql: str, params: list = None) -> DatabaseResult:
175
271
  if self._cache_enabled:
176
272
  self._cache_invalidate()
177
- return self._adapter.execute(sql, params)
273
+ adapter = self._get_adapter()
274
+ return adapter.execute(sql, params)
178
275
 
179
276
  def execute_many(self, sql: str, params_list: list[list] = None) -> DatabaseResult:
180
277
  if self._cache_enabled:
181
278
  self._cache_invalidate()
182
- return self._adapter.execute_many(sql, params_list)
279
+ adapter = self._get_adapter()
280
+ return adapter.execute_many(sql, params_list)
183
281
 
184
282
  def fetch(self, sql: str, params: list = None,
185
283
  limit: int = 20, offset: int = 0) -> DatabaseResult:
@@ -191,12 +289,14 @@ class Database:
191
289
  with self._cache_lock:
192
290
  self._cache_hits += 1
193
291
  return cached
194
- result = self._adapter.fetch(sql, params, limit, offset)
292
+ adapter = self._get_adapter()
293
+ result = adapter.fetch(sql, params, limit, offset)
195
294
  self._cache_set(key, result)
196
295
  with self._cache_lock:
197
296
  self._cache_misses += 1
198
297
  return result
199
- return self._adapter.fetch(sql, params, limit, offset)
298
+ adapter = self._get_adapter()
299
+ return adapter.fetch(sql, params, limit, offset)
200
300
 
201
301
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
202
302
  if self._cache_enabled:
@@ -206,59 +306,79 @@ class Database:
206
306
  with self._cache_lock:
207
307
  self._cache_hits += 1
208
308
  return cached
209
- result = self._adapter.fetch_one(sql, params)
309
+ adapter = self._get_adapter()
310
+ result = adapter.fetch_one(sql, params)
210
311
  self._cache_set(key, result)
211
312
  with self._cache_lock:
212
313
  self._cache_misses += 1
213
314
  return result
214
- return self._adapter.fetch_one(sql, params)
315
+ adapter = self._get_adapter()
316
+ return adapter.fetch_one(sql, params)
215
317
 
216
318
  def insert(self, table: str, data: dict | list) -> DatabaseResult:
217
319
  if self._cache_enabled:
218
320
  self._cache_invalidate()
219
- return self._adapter.insert(table, data)
321
+ adapter = self._get_adapter()
322
+ return adapter.insert(table, data)
220
323
 
221
324
  def update(self, table: str, data: dict,
222
325
  filter_sql: str = "", params: list = None) -> DatabaseResult:
223
326
  if self._cache_enabled:
224
327
  self._cache_invalidate()
225
- return self._adapter.update(table, data, filter_sql, params)
328
+ adapter = self._get_adapter()
329
+ return adapter.update(table, data, filter_sql, params)
226
330
 
227
331
  def delete(self, table: str,
228
332
  filter_sql: str | dict | list = "", params: list = None) -> DatabaseResult:
229
333
  if self._cache_enabled:
230
334
  self._cache_invalidate()
231
- return self._adapter.delete(table, filter_sql, params)
335
+ adapter = self._get_adapter()
336
+ return adapter.delete(table, filter_sql, params)
232
337
 
233
338
  def start_transaction(self):
234
- self._adapter.start_transaction()
339
+ adapter = self._get_adapter()
340
+ adapter.start_transaction()
235
341
 
236
342
  def commit(self):
237
- self._adapter.commit()
343
+ adapter = self._get_adapter()
344
+ adapter.commit()
238
345
 
239
346
  def rollback(self):
240
- self._adapter.rollback()
347
+ adapter = self._get_adapter()
348
+ adapter.rollback()
241
349
 
242
350
  def table_exists(self, name: str) -> bool:
243
- return self._adapter.table_exists(name)
351
+ adapter = self._get_adapter()
352
+ return adapter.table_exists(name)
244
353
 
245
354
  def get_tables(self) -> list[str]:
246
- return self._adapter.get_tables()
355
+ adapter = self._get_adapter()
356
+ return adapter.get_tables()
247
357
 
248
358
  def get_columns(self, table: str) -> list[dict]:
249
- return self._adapter.get_columns(table)
359
+ adapter = self._get_adapter()
360
+ return adapter.get_columns(table)
250
361
 
251
362
  def get_database_type(self) -> str:
252
- return self._adapter.get_database_type()
363
+ adapter = self._get_adapter()
364
+ return adapter.get_database_type()
253
365
 
254
366
  @property
255
367
  def autocommit(self) -> bool:
256
368
  """Whether writes auto-commit. Off by default, set TINA4_AUTOCOMMIT=true to enable."""
257
- return self._adapter.autocommit
369
+ adapter = self._get_adapter()
370
+ return adapter.autocommit
258
371
 
259
372
  @autocommit.setter
260
373
  def autocommit(self, value: bool):
261
- self._adapter.autocommit = value
374
+ if self._pool is not None:
375
+ # Set autocommit on all active pool connections
376
+ with self._pool._lock:
377
+ for a in self._pool._adapters:
378
+ if a is not None:
379
+ a.autocommit = value
380
+ elif self._adapter is not None:
381
+ self._adapter.autocommit = value
262
382
 
263
383
  def register_function(self, name: str, num_params: int, func: callable, deterministic: bool = True):
264
384
  """Register a custom SQL function (SQLite only).
@@ -267,14 +387,23 @@ class Database:
267
387
  db.register_function("double", 1, lambda x: x * 2)
268
388
  db.fetch_one("SELECT double(5) as result") # {"result": 10}
269
389
  """
270
- if hasattr(self._adapter, "register_function"):
271
- self._adapter.register_function(name, num_params, func, deterministic)
390
+ adapter = self._get_adapter()
391
+ if hasattr(adapter, "register_function"):
392
+ adapter.register_function(name, num_params, func, deterministic)
272
393
  else:
273
394
  raise NotImplementedError(
274
- f"{self._adapter.get_database_type()} does not support custom function registration"
395
+ f"{adapter.get_database_type()} does not support custom function registration"
275
396
  )
276
397
 
277
398
  @property
278
399
  def adapter(self) -> DatabaseAdapter:
279
- """Access the underlying adapter directly (for driver-specific ops)."""
280
- return self._adapter
400
+ """Access the underlying adapter directly (for driver-specific ops).
401
+
402
+ With pooling enabled, returns the next adapter from the pool via round-robin.
403
+ """
404
+ return self._get_adapter()
405
+
406
+ @property
407
+ def pool(self) -> ConnectionPool | None:
408
+ """Access the connection pool (None if pooling is disabled)."""
409
+ return self._pool
@@ -0,0 +1,177 @@
1
+ # Tina4 Test Client — Test routes without starting a server.
2
+ """
3
+ Simple test client that creates mock requests, matches routes,
4
+ executes handlers, and returns a TestResponse.
5
+
6
+ Usage::
7
+
8
+ from tina4_python.test_client import TestClient
9
+
10
+ client = TestClient()
11
+
12
+ response = client.get("/api/users")
13
+ assert response.status == 200
14
+ assert response.json()["users"] is not None
15
+
16
+ response = client.post("/api/users", json={"name": "Alice"})
17
+ assert response.status == 201
18
+
19
+ response = client.get("/api/users/1", headers={"Authorization": "Bearer token123"})
20
+ """
21
+ import json as _json
22
+ import asyncio
23
+ from tina4_python.core.request import Request
24
+ from tina4_python.core.response import Response
25
+ from tina4_python.core.router import Router
26
+
27
+
28
+ class TestResponse:
29
+ """Wraps a Response object with a clean test-friendly API."""
30
+
31
+ __slots__ = ("status", "body", "headers", "content_type")
32
+
33
+ def __init__(self, response: Response):
34
+ self.status: int = response.status_code
35
+ self.body: bytes = response.content
36
+ self.content_type: str = response.content_type
37
+ self.headers: dict = {}
38
+ for name, value in response._headers:
39
+ self.headers[name.lower()] = value
40
+
41
+ def json(self) -> dict | list | None:
42
+ """Parse body as JSON."""
43
+ if not self.body:
44
+ return None
45
+ return _json.loads(self.body.decode())
46
+
47
+ def text(self) -> str:
48
+ """Return body as a string."""
49
+ return self.body.decode(errors="replace")
50
+
51
+ def __repr__(self) -> str:
52
+ return f"<TestResponse status={self.status} content_type={self.content_type!r}>"
53
+
54
+
55
+ class TestClient:
56
+ """Test routes directly without starting a server.
57
+
58
+ Creates a mock Request, finds the matching route via Router.match(),
59
+ executes the handler, and returns a TestResponse.
60
+ """
61
+
62
+ def get(self, path: str, *, headers: dict | None = None) -> TestResponse:
63
+ """Send a GET request to the given path."""
64
+ return self._request("GET", path, headers=headers)
65
+
66
+ def post(self, path: str, *, json: dict | list | None = None,
67
+ body: str | bytes | None = None, headers: dict | None = None) -> TestResponse:
68
+ """Send a POST request to the given path."""
69
+ return self._request("POST", path, json=json, body=body, headers=headers)
70
+
71
+ def put(self, path: str, *, json: dict | list | None = None,
72
+ body: str | bytes | None = None, headers: dict | None = None) -> TestResponse:
73
+ """Send a PUT request to the given path."""
74
+ return self._request("PUT", path, json=json, body=body, headers=headers)
75
+
76
+ def patch(self, path: str, *, json: dict | list | None = None,
77
+ body: str | bytes | None = None, headers: dict | None = None) -> TestResponse:
78
+ """Send a PATCH request to the given path."""
79
+ return self._request("PATCH", path, json=json, body=body, headers=headers)
80
+
81
+ def delete(self, path: str, *, headers: dict | None = None) -> TestResponse:
82
+ """Send a DELETE request to the given path."""
83
+ return self._request("DELETE", path, headers=headers)
84
+
85
+ def _request(self, method: str, path: str, *,
86
+ json: dict | list | None = None,
87
+ body: str | bytes | None = None,
88
+ headers: dict | None = None) -> TestResponse:
89
+ """Build a mock request, match the route, execute the handler."""
90
+
91
+ # Build raw body bytes
92
+ raw_body = b""
93
+ content_type = ""
94
+
95
+ if json is not None:
96
+ raw_body = _json.dumps(json).encode()
97
+ content_type = "application/json"
98
+ elif body is not None:
99
+ if isinstance(body, str):
100
+ raw_body = body.encode()
101
+ else:
102
+ raw_body = body
103
+
104
+ # Build ASGI-style headers
105
+ header_list: list[tuple[bytes, bytes]] = []
106
+ if headers:
107
+ for k, v in headers.items():
108
+ header_list.append((k.lower().encode(), v.encode()))
109
+
110
+ if content_type and not any(h[0] == b"content-type" for h in header_list):
111
+ header_list.append((b"content-type", content_type.encode()))
112
+
113
+ if raw_body and not any(h[0] == b"content-length" for h in header_list):
114
+ header_list.append((b"content-length", str(len(raw_body)).encode()))
115
+
116
+ # Split path and query string
117
+ query_string = ""
118
+ clean_path = path
119
+ if "?" in path:
120
+ clean_path, query_string = path.split("?", 1)
121
+
122
+ # Build ASGI scope
123
+ scope = {
124
+ "type": "http",
125
+ "method": method,
126
+ "path": clean_path,
127
+ "query_string": query_string.encode(),
128
+ "headers": header_list,
129
+ "client": ("127.0.0.1", 0),
130
+ }
131
+
132
+ # Create Request from scope
133
+ request = Request.from_scope(scope, raw_body)
134
+
135
+ # Match route
136
+ route, params = Router.match(method, clean_path)
137
+
138
+ if route is None:
139
+ # No route found — return 404
140
+ resp = Response()
141
+ resp.status_code = 404
142
+ resp.content = b'{"error":"Not found"}'
143
+ resp.content_type = "application/json"
144
+ return TestResponse(resp)
145
+
146
+ # Inject route params
147
+ request._route_params = params
148
+ request.merge_route_params()
149
+
150
+ # Create response callable
151
+ response = Response()
152
+
153
+ # Execute handler (sync or async)
154
+ handler = route["handler"]
155
+ result = handler(request, response)
156
+
157
+ # If handler is async, run it in an event loop
158
+ if asyncio.iscoroutine(result):
159
+ try:
160
+ loop = asyncio.get_running_loop()
161
+ except RuntimeError:
162
+ loop = None
163
+
164
+ if loop and loop.is_running():
165
+ # Already in an async context — create a task
166
+ import concurrent.futures
167
+ with concurrent.futures.ThreadPoolExecutor() as pool:
168
+ result = pool.submit(asyncio.run, result).result()
169
+ else:
170
+ result = asyncio.run(result)
171
+
172
+ # The handler should have returned the response via response(...)
173
+ # If the handler returned a Response, use that
174
+ if isinstance(result, Response):
175
+ return TestResponse(result)
176
+
177
+ return TestResponse(response)