tina4-python 3.10.1__tar.gz → 3.10.3__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 (139) hide show
  1. {tina4_python-3.10.1 → tina4_python-3.10.3}/PKG-INFO +1 -1
  2. {tina4_python-3.10.1 → tina4_python-3.10.3}/pyproject.toml +1 -1
  3. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/server.py +94 -31
  4. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/connection.py +7 -4
  5. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/firebird.py +50 -26
  6. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/frond/engine.py +130 -17
  7. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/migration/runner.py +57 -6
  8. {tina4_python-3.10.1 → tina4_python-3.10.3}/.gitignore +0 -0
  9. {tina4_python-3.10.1 → tina4_python-3.10.3}/README.md +0 -0
  10. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/CLAUDE.md +0 -0
  11. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/HtmlElement.py +0 -0
  12. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/Testing.py +0 -0
  13. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/__init__.py +0 -0
  14. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/__init__.py +0 -0
  21. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/cache.py +0 -0
  22. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/constants.py +0 -0
  23. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/events.py +0 -0
  24. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/middleware.py +0 -0
  25. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/request.py +0 -0
  26. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/response.py +0 -0
  27. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/core/router.py +0 -0
  28. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/crud/__init__.py +0 -0
  29. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/__init__.py +0 -0
  30. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/adapter.py +0 -0
  31. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/mssql.py +0 -0
  32. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/mysql.py +0 -0
  33. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/odbc.py +0 -0
  34. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/postgres.py +0 -0
  35. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/database/sqlite.py +0 -0
  36. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/debug/__init__.py +0 -0
  37. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/dev_reload.py +0 -0
  40. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/dotenv/__init__.py +0 -0
  41. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/frond/FROND.md +0 -0
  42. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/frond/__init__.py +0 -0
  43. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/auth/meta.json +0 -0
  44. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  45. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/database/meta.json +0 -0
  46. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  47. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/error-overlay/meta.json +0 -0
  48. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  49. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/orm/meta.json +0 -0
  50. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  51. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  52. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/queue/meta.json +0 -0
  53. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  54. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/rest-api/meta.json +0 -0
  55. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  56. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/templates/meta.json +0 -0
  57. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  58. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  59. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/graphql/__init__.py +0 -0
  60. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/i18n/__init__.py +0 -0
  61. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/messenger/__init__.py +0 -0
  62. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/migration/__init__.py +0 -0
  63. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/orm/__init__.py +0 -0
  64. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/orm/fields.py +0 -0
  65. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/orm/model.py +0 -0
  66. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/css/tina4.css +0 -0
  67. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/css/tina4.min.css +0 -0
  68. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/favicon.ico +0 -0
  69. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/images/logo.svg +0 -0
  70. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  71. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/js/frond.min.js +0 -0
  72. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  73. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/js/tina4.min.js +0 -0
  74. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/js/tina4js.min.js +0 -0
  75. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/swagger/index.html +0 -0
  76. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  77. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/query_builder/__init__.py +0 -0
  78. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/queue/__init__.py +0 -0
  79. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/queue_backends/__init__.py +0 -0
  80. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/queue_backends/kafka_backend.py +0 -0
  81. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/queue_backends/mongo_backend.py +0 -0
  82. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  83. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/__init__.py +0 -0
  84. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  85. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_badges.scss +0 -0
  86. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  87. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_cards.scss +0 -0
  88. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_forms.scss +0 -0
  89. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_grid.scss +0 -0
  90. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_modals.scss +0 -0
  91. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_nav.scss +0 -0
  92. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_reset.scss +0 -0
  93. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_tables.scss +0 -0
  94. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_typography.scss +0 -0
  95. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  96. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/_variables.scss +0 -0
  97. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/base.scss +0 -0
  98. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/colors.scss +0 -0
  99. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/scss/tina4css/tina4.scss +0 -0
  100. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/seeder/__init__.py +0 -0
  101. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/service/__init__.py +0 -0
  102. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/session/__init__.py +0 -0
  103. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/session_handlers/__init__.py +0 -0
  104. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  105. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/session_handlers/redis_handler.py +0 -0
  106. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/session_handlers/valkey_handler.py +0 -0
  107. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/swagger/__init__.py +0 -0
  108. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/components/crud.twig +0 -0
  109. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  110. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  111. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/docker/python/Dockerfile +0 -0
  112. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  113. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/errors/302.twig +0 -0
  114. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/errors/401.twig +0 -0
  115. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/errors/403.twig +0 -0
  116. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/errors/404.twig +0 -0
  117. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/errors/500.twig +0 -0
  118. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/errors/502.twig +0 -0
  119. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/errors/503.twig +0 -0
  120. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/errors/base.twig +0 -0
  121. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/frontend/README.md +0 -0
  122. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/templates/readme.md +0 -0
  123. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/test_client/__init__.py +0 -0
  124. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  125. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  126. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  127. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  128. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  129. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  130. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  131. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  132. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  133. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  134. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  135. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  136. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/validator/__init__.py +0 -0
  137. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/websocket/__init__.py +0 -0
  138. {tina4_python-3.10.1 → tina4_python-3.10.3}/tina4_python/websocket/backplane.py +0 -0
  139. {tina4_python-3.10.1 → tina4_python-3.10.3}/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.10.1
3
+ Version: 3.10.3
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.10.1"
3
+ version = "3.10.3"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -772,40 +772,98 @@ async def app(scope: dict, receive, send):
772
772
  request._route_params = params
773
773
  request.merge_route_params()
774
774
  try:
775
- import inspect
776
- _sig = inspect.signature(route["handler"])
777
- _params = list(_sig.parameters.values())
778
- _pcount = len(_params)
779
-
780
- # Build args: inject path params by name, then request/response
781
- _args = []
782
- _remaining = []
783
- for p in _params:
784
- name = p.name
785
- if name in params:
786
- _args.append(params[name])
787
- else:
788
- _remaining.append(p)
789
-
790
- # Append request/response for remaining positional params
791
- if len(_remaining) == 0:
792
- pass # All params were path params
793
- elif len(_remaining) == 1:
794
- _ann = _remaining[0].annotation
795
- if _ann is Request or (isinstance(_ann, str) and _ann in ("Request", "request")):
775
+ # ── Auth enforcement ────────────────────────────────────
776
+ _skip_handler = False
777
+ if route.get("auth_required"):
778
+ _auth_header = request.headers.get("authorization", "")
779
+ _api_key = os.environ.get("TINA4_API_KEY", os.environ.get("API_KEY", ""))
780
+ _auth_ok = False
781
+ if _auth_header:
782
+ if _auth_header.startswith("Bearer "):
783
+ _token = _auth_header[7:]
784
+ # Check static API key first
785
+ if _api_key and _token == _api_key:
786
+ _auth_ok = True
787
+ else:
788
+ # Validate JWT token
789
+ try:
790
+ from tina4_python.auth import Auth
791
+ if Auth.valid_token(_token):
792
+ _auth_ok = True
793
+ except Exception:
794
+ pass
795
+ if not _auth_ok:
796
+ response.status(401).json({
797
+ "error": "Unauthorized",
798
+ "message": "Valid authorization token required",
799
+ "status": 401,
800
+ })
801
+ _skip_handler = True
802
+
803
+ # ── Route middleware (before_* methods) ─────────────────
804
+ if not _skip_handler:
805
+ for _mw_cls in route.get("middleware", []):
806
+ _mw_inst = _mw_cls() if isinstance(_mw_cls, type) else _mw_cls
807
+ for _attr_name in dir(_mw_inst):
808
+ if _attr_name.startswith("before_"):
809
+ _mw_method = getattr(_mw_inst, _attr_name)
810
+ if callable(_mw_method):
811
+ _mw_result = _mw_method(request, response)
812
+ if _mw_result is not None:
813
+ request, response = _mw_result
814
+ # If middleware returned an error status, skip handler
815
+ if response.status_code >= 400:
816
+ _skip_handler = True
817
+ break
818
+ if _skip_handler:
819
+ break
820
+
821
+ if not _skip_handler:
822
+ import inspect
823
+ _sig = inspect.signature(route["handler"])
824
+ _params = list(_sig.parameters.values())
825
+ _pcount = len(_params)
826
+
827
+ # Build args: inject path params by name, then request/response
828
+ _args = []
829
+ _remaining = []
830
+ for p in _params:
831
+ name = p.name
832
+ if name in params:
833
+ _args.append(params[name])
834
+ else:
835
+ _remaining.append(p)
836
+
837
+ # Append request/response for remaining positional params
838
+ if len(_remaining) == 0:
839
+ pass # All params were path params
840
+ elif len(_remaining) == 1:
841
+ _ann = _remaining[0].annotation
842
+ if _ann is Request or (isinstance(_ann, str) and _ann in ("Request", "request")):
843
+ _args.append(request)
844
+ else:
845
+ _args.append(response)
846
+ elif len(_remaining) >= 2:
796
847
  _args.append(request)
797
- else:
798
848
  _args.append(response)
799
- elif len(_remaining) >= 2:
800
- _args.append(request)
801
- _args.append(response)
802
849
 
803
- if _pcount == 0:
804
- result = await route["handler"]()
805
- else:
806
- result = await route["handler"](*_args)
807
- if isinstance(result, Response):
808
- response = result
850
+ if _pcount == 0:
851
+ result = await route["handler"]()
852
+ else:
853
+ result = await route["handler"](*_args)
854
+ if isinstance(result, Response):
855
+ response = result
856
+
857
+ # ── Route middleware (after_* methods) ──────────────────
858
+ for _mw_cls in route.get("middleware", []):
859
+ _mw_inst = _mw_cls() if isinstance(_mw_cls, type) else _mw_cls
860
+ for _attr_name in dir(_mw_inst):
861
+ if _attr_name.startswith("after_"):
862
+ _mw_method = getattr(_mw_inst, _attr_name)
863
+ if callable(_mw_method):
864
+ _mw_result = _mw_method(request, response)
865
+ if _mw_result is not None:
866
+ request, response = _mw_result
809
867
  except Exception as e:
810
868
  Log.error(f"Route error: {e}", path=request.path)
811
869
  _write_broken(request, e)
@@ -1135,6 +1193,11 @@ def run(host: str | None = None, port: int | None = None):
1135
1193
  global _start_time
1136
1194
  _start_time = time.time()
1137
1195
 
1196
+ # Ensure CWD is on sys.path so auto-discovered modules can be imported
1197
+ cwd = os.getcwd()
1198
+ if cwd not in sys.path:
1199
+ sys.path.insert(0, cwd)
1200
+
1138
1201
  # Load .env first so env vars are available for logger init
1139
1202
  from tina4_python.dotenv import load_env
1140
1203
  load_env()
@@ -36,12 +36,13 @@ class ConnectionPool:
36
36
  """
37
37
 
38
38
  def __init__(self, pool_size: int, factory: callable, connect_path: str,
39
- username: str = "", password: str = ""):
39
+ username: str = "", password: str = "", **kwargs):
40
40
  self._pool_size = pool_size
41
41
  self._factory = factory
42
42
  self._connect_path = connect_path
43
43
  self._username = username
44
44
  self._password = password
45
+ self._connect_kwargs = kwargs
45
46
  self._adapters: list[DatabaseAdapter | None] = [None] * pool_size
46
47
  self._index = 0
47
48
  self._lock = threading.Lock()
@@ -50,7 +51,7 @@ class ConnectionPool:
50
51
  """Lazily create an adapter at the given index."""
51
52
  if self._adapters[idx] is None:
52
53
  adapter = self._factory()
53
- adapter.connect(self._connect_path, username=self._username, password=self._password)
54
+ adapter.connect(self._connect_path, username=self._username, password=self._password, **self._connect_kwargs)
54
55
  self._adapters[idx] = adapter
55
56
  return self._adapters[idx]
56
57
 
@@ -130,12 +131,13 @@ class Database:
130
131
  operations to the adapter. This is what the rest of the framework uses.
131
132
  """
132
133
 
133
- def __init__(self, url: str = None, username: str = "", password: str = "", pool: int = 0):
134
+ def __init__(self, url: str = None, username: str = "", password: str = "", pool: int = 0, **kwargs):
134
135
  self.url = url or os.environ.get("DATABASE_URL", "sqlite:///data/tina4.db")
135
136
  # Priority: constructor params > env vars > empty
136
137
  self.username = username or os.environ.get("DATABASE_USERNAME", "")
137
138
  self.password = password or os.environ.get("DATABASE_PASSWORD", "")
138
139
  self.pool_size = pool # 0 = single connection, N>0 = N pooled connections
140
+ self._connect_kwargs = kwargs # Extra kwargs passed through to adapter.connect()
139
141
 
140
142
  if self.pool_size > 0:
141
143
  # Pooled mode — create a ConnectionPool with lazy adapter creation
@@ -145,13 +147,14 @@ class Database:
145
147
  connect_path=self._connection_path(),
146
148
  username=self.username,
147
149
  password=self.password,
150
+ **kwargs,
148
151
  )
149
152
  self._adapter: DatabaseAdapter | None = None
150
153
  else:
151
154
  # Single-connection mode — current behavior
152
155
  self._pool: ConnectionPool | None = None
153
156
  self._adapter: DatabaseAdapter = self._create_adapter()
154
- self._adapter.connect(self._connection_path(), username=self.username, password=self.password)
157
+ self._adapter.connect(self._connection_path(), username=self.username, password=self.password, **kwargs)
155
158
 
156
159
  # Query cache — off by default, opt-in via TINA4_DB_CACHE=true
157
160
  from tina4_python.dotenv import is_truthy
@@ -1,18 +1,32 @@
1
- # Tina4 Firebird Driver — Uses fdb (optional).
1
+ # Tina4 Firebird Driver — Uses firebird-driver or fdb (optional).
2
2
  """
3
- Firebird adapter using fdb.
3
+ Firebird adapter using firebird-driver (preferred) or fdb (fallback).
4
4
 
5
5
  db = Database("firebird://user:pass@localhost:3050/path/to/database.fdb")
6
6
 
7
- Requires: pip install fdb
7
+ Requires: pip install firebird-driver (or pip install fdb for legacy)
8
8
  """
9
9
  import re
10
10
  from urllib.parse import urlparse, unquote
11
11
  from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult, SQLTranslator
12
12
 
13
+ # Try modern firebird-driver first, fall back to legacy fdb
14
+ _driver = None
15
+ _driver_name = None
16
+ try:
17
+ import firebird.driver as _driver
18
+ _driver_name = "firebird-driver"
19
+ except ImportError:
20
+ try:
21
+ import fdb as _driver
22
+ _driver_name = "fdb"
23
+ except ImportError:
24
+ _driver = None
25
+ _driver_name = None
26
+
13
27
 
14
28
  class FirebirdAdapter(DatabaseAdapter):
15
- """Firebird database driver using fdb."""
29
+ """Firebird database driver using firebird-driver or fdb."""
16
30
 
17
31
  def __init__(self):
18
32
  super().__init__()
@@ -25,12 +39,10 @@ class FirebirdAdapter(DatabaseAdapter):
25
39
  Connection string: firebird://user:pass@host:port/path/to/db.fdb
26
40
  Credentials priority: URL > username/password params > adapter defaults (SYSDBA/masterkey).
27
41
  """
28
- try:
29
- import fdb
30
- except ImportError:
42
+ if _driver is None:
31
43
  raise ImportError(
32
- "fdb is required for Firebird connections. "
33
- "Install: pip install fdb"
44
+ "A Firebird driver is required. "
45
+ "Install: pip install firebird-driver (or pip install fdb for legacy)"
34
46
  )
35
47
 
36
48
  parsed = urlparse(connection_string)
@@ -42,15 +54,27 @@ class FirebirdAdapter(DatabaseAdapter):
42
54
  password = parsed.password or password or "masterkey"
43
55
  charset = kwargs.pop("charset", "UTF8")
44
56
 
45
- self._conn = fdb.connect(
46
- host=host,
47
- port=port,
48
- database=db_path,
49
- user=user,
50
- password=password,
51
- charset=charset,
52
- **kwargs,
53
- )
57
+ if _driver_name == "firebird-driver":
58
+ # Modern firebird-driver uses dsn format: host/port:path
59
+ dsn = f"{host}/{port}:{db_path}" if port != 3050 else f"{host}:{db_path}"
60
+ self._conn = _driver.connect(
61
+ dsn,
62
+ user=user,
63
+ password=password,
64
+ charset=charset,
65
+ **kwargs,
66
+ )
67
+ else:
68
+ # Legacy fdb
69
+ self._conn = _driver.connect(
70
+ host=host,
71
+ port=port,
72
+ database=db_path,
73
+ user=user,
74
+ password=password,
75
+ charset=charset,
76
+ **kwargs,
77
+ )
54
78
 
55
79
  def close(self):
56
80
  if self._conn:
@@ -94,7 +118,7 @@ class FirebirdAdapter(DatabaseAdapter):
94
118
  desc = cursor.description
95
119
  row = cursor.fetchone()
96
120
  if row and desc:
97
- col_names = [d[0] for d in desc]
121
+ col_names = [d[0].strip().lower() for d in desc]
98
122
  records = [dict(zip(col_names, row))]
99
123
  except Exception:
100
124
  pass
@@ -133,7 +157,7 @@ class FirebirdAdapter(DatabaseAdapter):
133
157
  cursor.execute(paginated_sql, params or [])
134
158
 
135
159
  desc = cursor.description
136
- col_names = [d[0] for d in desc] if desc else []
160
+ col_names = [d[0].strip().lower() for d in desc] if desc else []
137
161
  rows = [dict(zip(col_names, row)) for row in cursor.fetchall()]
138
162
 
139
163
  return DatabaseResult(records=rows, count=total, sql=sql, adapter=self)
@@ -146,7 +170,7 @@ class FirebirdAdapter(DatabaseAdapter):
146
170
  row = cursor.fetchone()
147
171
  if row is None:
148
172
  return None
149
- col_names = [d[0] for d in desc] if desc else []
173
+ col_names = [d[0].strip().lower() for d in desc] if desc else []
150
174
  return dict(zip(col_names, row))
151
175
 
152
176
  def insert(self, table: str, data: dict) -> DatabaseResult:
@@ -204,7 +228,7 @@ class FirebirdAdapter(DatabaseAdapter):
204
228
  "ORDER BY RDB$RELATION_NAME",
205
229
  limit=10000,
206
230
  )
207
- return [r["RDB$RELATION_NAME"].strip() for r in result.records]
231
+ return [r["rdb$relation_name"].strip() for r in result.records]
208
232
 
209
233
  def get_columns(self, table: str) -> list[dict]:
210
234
  sql = (
@@ -224,10 +248,10 @@ class FirebirdAdapter(DatabaseAdapter):
224
248
  }
225
249
  return [
226
250
  {
227
- "name": r["RDB$FIELD_NAME"].strip() if r["RDB$FIELD_NAME"] else "",
228
- "type": type_map.get(r.get("RDB$FIELD_TYPE"), str(r.get("RDB$FIELD_TYPE", ""))),
229
- "nullable": r.get("RDB$NULL_FLAG") is None,
230
- "default": r.get("RDB$DEFAULT_SOURCE"),
251
+ "name": r["rdb$field_name"].strip() if r["rdb$field_name"] else "",
252
+ "type": type_map.get(r.get("rdb$field_type"), str(r.get("rdb$field_type", ""))),
253
+ "nullable": r.get("rdb$null_flag") is None,
254
+ "default": r.get("rdb$default_source"),
231
255
  "primary_key": False,
232
256
  }
233
257
  for r in result.records
@@ -181,6 +181,60 @@ def _find_colon(expr: str) -> int:
181
181
  # ── Expression Evaluator ────────────────────────────────────────
182
182
 
183
183
 
184
+ def _split_dotted(expr: str) -> list[str]:
185
+ """Split a dotted expression into parts, respecting quotes, parens, and brackets.
186
+
187
+ 'user.t("auth.email")' → ['user', 't("auth.email")']
188
+ 'items[0].name' → ['items', '[0]', 'name']
189
+ 'a.b.c' → ['a', 'b', 'c']
190
+ """
191
+ parts = []
192
+ current = ""
193
+ in_q = None
194
+ paren_depth = 0
195
+ bracket_depth = 0
196
+ i = 0
197
+ while i < len(expr):
198
+ ch = expr[i]
199
+ if ch in ('"', "'") and paren_depth == 0 and bracket_depth == 0 and in_q is None:
200
+ in_q = ch
201
+ current += ch
202
+ elif ch == in_q:
203
+ in_q = None
204
+ current += ch
205
+ elif in_q:
206
+ current += ch
207
+ elif ch == "(":
208
+ paren_depth += 1
209
+ current += ch
210
+ elif ch == ")":
211
+ paren_depth -= 1
212
+ current += ch
213
+ elif ch == "[" and paren_depth == 0:
214
+ # Start of bracket access — save current part if any
215
+ if current:
216
+ parts.append(current)
217
+ current = ""
218
+ bracket_depth += 1
219
+ current += ch
220
+ elif ch == "]" and bracket_depth > 0:
221
+ bracket_depth -= 1
222
+ current += ch
223
+ if bracket_depth == 0:
224
+ parts.append(current)
225
+ current = ""
226
+ elif ch == "." and paren_depth == 0 and bracket_depth == 0:
227
+ if current:
228
+ parts.append(current)
229
+ current = ""
230
+ else:
231
+ current += ch
232
+ i += 1
233
+ if current:
234
+ parts.append(current)
235
+ return parts
236
+
237
+
184
238
  def _resolve(expr: str, context: dict):
185
239
  """Resolve a dotted expression against the context.
186
240
 
@@ -209,29 +263,59 @@ def _resolve(expr: str, context: dict):
209
263
  if expr in ("null", "none", "None"):
210
264
  return None
211
265
 
212
- # Dotted path with bracket access
213
- parts = re.split(r"\.|(\[[^\]]+\])", expr)
214
- parts = [p for p in parts if p]
266
+ # Dotted path with bracket access — split respecting quotes and parens
267
+ parts = _split_dotted(expr)
215
268
 
216
269
  value = context
217
270
  for part in parts:
218
271
  if part.startswith("[") and part.endswith("]"):
219
272
  idx = part[1:-1].strip("'\"")
220
- try:
221
- idx = int(idx)
222
- except ValueError:
223
- pass
224
- try:
225
- value = value[idx]
226
- except (KeyError, IndexError, TypeError):
227
- return None
228
- elif isinstance(value, dict):
229
- value = value.get(part)
230
- elif hasattr(value, part):
231
- attr = getattr(value, part)
232
- value = attr() if callable(attr) else attr
273
+ # Slice syntax: value[1:5], value[:10], value[3:]
274
+ if ":" in idx:
275
+ slice_parts = idx.split(":", 1)
276
+ s_start = int(slice_parts[0]) if slice_parts[0].strip() else None
277
+ s_end = int(slice_parts[1]) if slice_parts[1].strip() else None
278
+ try:
279
+ value = value[s_start:s_end]
280
+ except (TypeError, IndexError):
281
+ return None
282
+ else:
283
+ try:
284
+ idx = int(idx)
285
+ except ValueError:
286
+ pass
287
+ try:
288
+ value = value[idx]
289
+ except (KeyError, IndexError, TypeError):
290
+ return None
233
291
  else:
234
- return None
292
+ # Check if this part is a method call: name(args)
293
+ call_match = re.match(r"^(\w+)\s*\((.*)?\)$", part, re.DOTALL)
294
+ if call_match:
295
+ method_name = call_match.group(1)
296
+ raw_args = call_match.group(2) or ""
297
+ # Resolve the callable from the current value
298
+ if isinstance(value, dict):
299
+ fn = value.get(method_name)
300
+ elif hasattr(value, method_name):
301
+ fn = getattr(value, method_name)
302
+ else:
303
+ return None
304
+ if callable(fn):
305
+ if raw_args.strip():
306
+ args = [_eval_expr(a.strip(), context) for a in _split_args(raw_args)]
307
+ else:
308
+ args = []
309
+ value = fn(*args)
310
+ else:
311
+ return None
312
+ elif isinstance(value, dict):
313
+ value = value.get(part)
314
+ elif hasattr(value, part):
315
+ attr = getattr(value, part)
316
+ value = attr() if callable(attr) else attr
317
+ else:
318
+ return None
235
319
 
236
320
  if value is None:
237
321
  return None
@@ -239,6 +323,35 @@ def _resolve(expr: str, context: dict):
239
323
  return value
240
324
 
241
325
 
326
+ def _split_args(raw: str) -> list[str]:
327
+ """Split comma-separated arguments respecting quotes and nested parens."""
328
+ parts = []
329
+ current = ""
330
+ in_q = None
331
+ depth = 0
332
+ for ch in raw:
333
+ if ch in ('"', "'") and not in_q:
334
+ in_q = ch
335
+ current += ch
336
+ elif ch == in_q:
337
+ in_q = None
338
+ current += ch
339
+ elif ch == "(" and not in_q:
340
+ depth += 1
341
+ current += ch
342
+ elif ch == ")" and not in_q:
343
+ depth -= 1
344
+ current += ch
345
+ elif ch == "," and not in_q and depth == 0:
346
+ parts.append(current.strip())
347
+ current = ""
348
+ else:
349
+ current += ch
350
+ if current.strip():
351
+ parts.append(current.strip())
352
+ return parts
353
+
354
+
242
355
  def _eval_expr(expr: str, context: dict):
243
356
  """Evaluate a full expression (with ~, ternary, ??, comparisons)."""
244
357
  expr = expr.strip()
@@ -18,7 +18,11 @@ logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
20
  def _ensure_tracking_table(db):
21
- """Create the migration tracking table if it doesn't exist."""
21
+ """Create or upgrade the migration tracking table.
22
+
23
+ Handles v2→v3 upgrade: v2 tables have `description` but no `migration_id`.
24
+ When detected, adds the missing column and backfills from `description`.
25
+ """
22
26
  if not db.table_exists("tina4_migration"):
23
27
  db.execute("""
24
28
  CREATE TABLE tina4_migration (
@@ -31,15 +35,62 @@ def _ensure_tracking_table(db):
31
35
  )
32
36
  """)
33
37
  db.commit()
38
+ return
39
+
40
+ # Check if this is a v2 table (has description but no migration_id column)
41
+ try:
42
+ db.fetch_one("SELECT migration_id FROM tina4_migration WHERE 1=0")
43
+ except Exception:
44
+ # migration_id column doesn't exist — v2 schema, upgrade it
45
+ try:
46
+ db.execute("ALTER TABLE tina4_migration ADD migration_id TEXT")
47
+ db.commit()
48
+ except Exception:
49
+ pass # Column may already exist on some engines
50
+
51
+ # Backfill migration_id from description (v2 used description as the identifier)
52
+ try:
53
+ db.execute("UPDATE tina4_migration SET migration_id = description WHERE migration_id IS NULL")
54
+ db.commit()
55
+ except Exception:
56
+ pass
57
+
58
+ # Add batch column if missing (v2 didn't have it)
59
+ try:
60
+ db.fetch_one("SELECT batch FROM tina4_migration WHERE 1=0")
61
+ except Exception:
62
+ try:
63
+ db.execute("ALTER TABLE tina4_migration ADD batch INTEGER DEFAULT 1")
64
+ db.commit()
65
+ except Exception:
66
+ pass
67
+
68
+ # Add executed_at column if missing
69
+ try:
70
+ db.fetch_one("SELECT executed_at FROM tina4_migration WHERE 1=0")
71
+ except Exception:
72
+ try:
73
+ db.execute("ALTER TABLE tina4_migration ADD executed_at TEXT DEFAULT ''")
74
+ db.commit()
75
+ except Exception:
76
+ pass
34
77
 
35
78
 
36
79
  def _get_executed(db) -> set[str]:
37
80
  """Get set of already-executed migration IDs."""
38
- result = db.fetch(
39
- "SELECT migration_id FROM tina4_migration WHERE passed = 1",
40
- limit=10000,
41
- )
42
- return {row["migration_id"] for row in result.records}
81
+ try:
82
+ result = db.fetch(
83
+ "SELECT migration_id FROM tina4_migration WHERE passed = 1",
84
+ limit=10000,
85
+ )
86
+ return {row["migration_id"] for row in result.records if row.get("migration_id")}
87
+ except Exception:
88
+ # Fallback for v2 tables where migration_id may not exist yet
89
+ result = db.fetch(
90
+ "SELECT description FROM tina4_migration WHERE passed = 1",
91
+ limit=10000,
92
+ )
93
+ return {row["description"] for row in result.records if row.get("description")}
43
94
 
44
95
 
45
96
  def _get_next_batch(db) -> int:
File without changes
File without changes