tina4-python 3.6.0__tar.gz → 3.7.1__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 (135) hide show
  1. {tina4_python-3.6.0 → tina4_python-3.7.1}/PKG-INFO +1 -1
  2. {tina4_python-3.6.0 → tina4_python-3.7.1}/pyproject.toml +1 -1
  3. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/server.py +56 -17
  4. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/migration/runner.py +68 -0
  5. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/queue/__init__.py +2 -2
  6. {tina4_python-3.6.0 → tina4_python-3.7.1}/.gitignore +0 -0
  7. {tina4_python-3.6.0 → tina4_python-3.7.1}/README.md +0 -0
  8. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/CLAUDE.md +0 -0
  9. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/HtmlElement.py +0 -0
  10. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/Testing.py +0 -0
  11. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/__init__.py +0 -0
  12. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/ai/__init__.py +0 -0
  13. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/api/__init__.py +0 -0
  14. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/auth/__init__.py +0 -0
  15. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/cache/__init__.py +0 -0
  16. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/cli/__init__.py +0 -0
  17. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/container/__init__.py +0 -0
  18. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/__init__.py +0 -0
  19. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/cache.py +0 -0
  20. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/constants.py +0 -0
  21. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/events.py +0 -0
  22. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/middleware.py +0 -0
  23. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/request.py +0 -0
  24. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/response.py +0 -0
  25. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/core/router.py +0 -0
  26. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/crud/__init__.py +0 -0
  27. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/__init__.py +0 -0
  28. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/adapter.py +0 -0
  29. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/connection.py +0 -0
  30. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/firebird.py +0 -0
  31. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/mssql.py +0 -0
  32. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/mysql.py +0 -0
  33. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/odbc.py +0 -0
  34. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/postgres.py +0 -0
  35. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/database/sqlite.py +0 -0
  36. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/debug/__init__.py +0 -0
  37. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/dev_reload.py +0 -0
  40. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/dotenv/__init__.py +0 -0
  41. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/frond/FROND.md +0 -0
  42. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/frond/__init__.py +0 -0
  43. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/frond/engine.py +0 -0
  44. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/auth/meta.json +0 -0
  45. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  46. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/database/meta.json +0 -0
  47. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  48. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/error-overlay/meta.json +0 -0
  49. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  50. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/orm/meta.json +0 -0
  51. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  52. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  53. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/queue/meta.json +0 -0
  54. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  55. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/rest-api/meta.json +0 -0
  56. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  57. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/templates/meta.json +0 -0
  58. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  59. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  60. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/graphql/__init__.py +0 -0
  61. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/i18n/__init__.py +0 -0
  62. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/messenger/__init__.py +0 -0
  63. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/migration/__init__.py +0 -0
  64. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/orm/__init__.py +0 -0
  65. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/orm/fields.py +0 -0
  66. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/orm/model.py +0 -0
  67. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/css/tina4.css +0 -0
  68. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/css/tina4.min.css +0 -0
  69. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/favicon.ico +0 -0
  70. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/images/logo.svg +0 -0
  71. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  72. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/js/frond.min.js +0 -0
  73. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  74. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/js/tina4.min.js +0 -0
  75. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/js/tina4js.min.js +0 -0
  76. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/swagger/index.html +0 -0
  77. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  78. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/queue_backends/__init__.py +0 -0
  79. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/queue_backends/kafka_backend.py +0 -0
  80. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/queue_backends/mongo_backend.py +0 -0
  81. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  82. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/__init__.py +0 -0
  83. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  84. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_badges.scss +0 -0
  85. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  86. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_cards.scss +0 -0
  87. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_forms.scss +0 -0
  88. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_grid.scss +0 -0
  89. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_modals.scss +0 -0
  90. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_nav.scss +0 -0
  91. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_reset.scss +0 -0
  92. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_tables.scss +0 -0
  93. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_typography.scss +0 -0
  94. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  95. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_variables.scss +0 -0
  96. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/base.scss +0 -0
  97. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/colors.scss +0 -0
  98. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/tina4.scss +0 -0
  99. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/seeder/__init__.py +0 -0
  100. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/service/__init__.py +0 -0
  101. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/session/__init__.py +0 -0
  102. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/session_handlers/__init__.py +0 -0
  103. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  104. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/session_handlers/redis_handler.py +0 -0
  105. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/session_handlers/valkey_handler.py +0 -0
  106. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/swagger/__init__.py +0 -0
  107. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/components/crud.twig +0 -0
  108. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  109. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  110. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/docker/python/Dockerfile +0 -0
  111. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  112. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/errors/302.twig +0 -0
  113. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/errors/401.twig +0 -0
  114. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/errors/403.twig +0 -0
  115. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/errors/404.twig +0 -0
  116. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/errors/500.twig +0 -0
  117. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/errors/502.twig +0 -0
  118. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/errors/503.twig +0 -0
  119. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/errors/base.twig +0 -0
  120. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/frontend/README.md +0 -0
  121. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/templates/readme.md +0 -0
  122. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  123. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  124. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  125. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  126. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  127. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  128. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  129. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  130. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  131. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  132. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  133. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  134. {tina4_python-3.6.0 → tina4_python-3.7.1}/tina4_python/websocket/__init__.py +0 -0
  135. {tina4_python-3.6.0 → tina4_python-3.7.1}/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.6.0
3
+ Version: 3.7.1
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.6.0"
3
+ version = "3.7.1"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -136,13 +136,45 @@ def _render_error_page(status_code: int, path: str, request_id: str, error_messa
136
136
  return None
137
137
 
138
138
 
139
- def _has_index_template() -> bool:
140
- """Check if the user has an index template in src/templates/."""
139
+ _template_cache: dict[str, str] | None = None
140
+
141
+
142
+ def _resolve_template(path: str) -> str | None:
143
+ """Resolve a URL path to a template file in src/templates/.
144
+ Dev mode: checks filesystem every time for live changes.
145
+ Production: uses a cached lookup built once at startup.
146
+ """
147
+ clean_path = path.strip("/") or "index"
148
+ is_dev = os.environ.get("TINA4_DEBUG", "false").lower() in ("true", "1", "yes")
149
+
150
+ if is_dev:
151
+ template_dir = Path("src/templates")
152
+ for ext in (".twig", ".html"):
153
+ candidate = clean_path + ext
154
+ if (template_dir / candidate).is_file():
155
+ return candidate
156
+ return None
157
+
158
+ global _template_cache
159
+ if _template_cache is None:
160
+ _build_template_cache()
161
+ return _template_cache.get(clean_path)
162
+
163
+
164
+ def _build_template_cache() -> None:
165
+ """Scan src/templates/ once and build url_path -> template_file lookup."""
166
+ global _template_cache
167
+ _template_cache = {}
141
168
  template_dir = Path("src/templates")
142
- for name in ("index.html", "index.twig", "index.php", "index.erb"):
143
- if (template_dir / name).is_file():
144
- return True
145
- return False
169
+ if not template_dir.is_dir():
170
+ return
171
+ for f in template_dir.rglob("*"):
172
+ if not f.is_file() or f.suffix not in (".twig", ".html"):
173
+ continue
174
+ rel = str(f.relative_to(template_dir)).replace("\\", "/")
175
+ url_path = rel.rsplit(".", 1)[0]
176
+ if url_path not in _template_cache:
177
+ _template_cache[url_path] = rel
146
178
 
147
179
 
148
180
  def _is_gallery_deployed(name: str) -> bool:
@@ -779,19 +811,26 @@ async def app(scope: dict, receive, send):
779
811
  static = _try_static(request.path)
780
812
  if static:
781
813
  response = static
782
- elif request.path == "/" and not _has_index_template():
783
- # No "/" route registered and no index template — show default landing page
784
- response.html(_render_landing_page())
785
814
  else:
786
- html = _render_error_page(404, request.path, request_id)
787
- if html:
788
- response.status(404).html(html)
815
+ # Try serving a template file (e.g. /hello -> src/templates/hello.twig or hello.html)
816
+ tpl_file = _resolve_template(request.path)
817
+ if tpl_file:
818
+ from tina4_python.frond import Frond
819
+ frond = Frond()
820
+ html = frond.render(tpl_file, {})
821
+ response.html(html)
822
+ elif request.path == "/":
823
+ response.html(_render_landing_page())
789
824
  else:
790
- response.status(404).json({
791
- "error": "Not Found",
792
- "path": request.path,
793
- "status": 404,
794
- })
825
+ html = _render_error_page(404, request.path, request_id)
826
+ if html:
827
+ response.status(404).html(html)
828
+ else:
829
+ response.status(404).json({
830
+ "error": "Not Found",
831
+ "path": request.path,
832
+ "status": 404,
833
+ })
795
834
 
796
835
  # Apply CORS headers to all responses
797
836
  _cors.apply(request, response)
@@ -8,11 +8,14 @@ Both naming patterns are supported. New migrations use timestamp format by defau
8
8
  Each file is executed once. State tracked in tina4_migration table.
9
9
  Rollback uses matching .down.sql files.
10
10
  """
11
+ import logging
11
12
  import os
12
13
  import re
13
14
  from pathlib import Path
14
15
  from datetime import datetime, timezone
15
16
 
17
+ logger = logging.getLogger(__name__)
18
+
16
19
 
17
20
  def _ensure_tracking_table(db):
18
21
  """Create the migration tracking table if it doesn't exist."""
@@ -94,6 +97,64 @@ def _split_statements(sql: str, delimiter: str = ";") -> list[str]:
94
97
  return statements
95
98
 
96
99
 
100
+ def _is_firebird(db) -> bool:
101
+ """Check if the database connection is Firebird."""
102
+ try:
103
+ return db.get_database_type() == "firebird"
104
+ except (AttributeError, Exception):
105
+ return False
106
+
107
+
108
+ # Regex to match ALTER TABLE <table> ADD <column> ...
109
+ # Captures the table name and column name from the SQL statement.
110
+ _ALTER_ADD_RE = re.compile(
111
+ r"^\s*ALTER\s+TABLE\s+"
112
+ r"(?:\"([^\"]+)\"|(\S+))" # table name (quoted or unquoted)
113
+ r"\s+ADD\s+"
114
+ r"(?:\"([^\"]+)\"|(\S+))", # column name (quoted or unquoted)
115
+ re.IGNORECASE,
116
+ )
117
+
118
+
119
+ def _firebird_column_exists(db, table: str, column: str) -> bool:
120
+ """Check if a column already exists in a Firebird table via RDB$RELATION_FIELDS.
121
+
122
+ Firebird does not support IF NOT EXISTS for ALTER TABLE ADD, so we query
123
+ the system catalogue directly. Column and table names are compared in
124
+ upper-case because Firebird stores unquoted identifiers that way.
125
+ """
126
+ row = db.fetch_one(
127
+ "SELECT 1 FROM RDB$RELATION_FIELDS "
128
+ "WHERE RDB$RELATION_NAME = ? AND TRIM(RDB$FIELD_NAME) = ?",
129
+ [table.upper(), column.upper()],
130
+ )
131
+ return row is not None
132
+
133
+
134
+ def _should_skip_for_firebird(db, stmt: str) -> str | None:
135
+ """If stmt is an ALTER TABLE ... ADD on Firebird and the column already exists, return a skip reason.
136
+
137
+ Returns None if the statement should be executed normally.
138
+ This makes ALTER TABLE ADD idempotent on Firebird, which lacks IF NOT EXISTS
139
+ for column additions. Only genuine duplicates are skipped — other errors
140
+ (bad syntax, wrong data type, etc.) will still raise normally on execute().
141
+ """
142
+ if not _is_firebird(db):
143
+ return None
144
+
145
+ m = _ALTER_ADD_RE.match(stmt)
146
+ if not m:
147
+ return None
148
+
149
+ table = m.group(1) or m.group(2)
150
+ column = m.group(3) or m.group(4)
151
+
152
+ if _firebird_column_exists(db, table, column):
153
+ return f"Column {column} already exists in {table}, skipping"
154
+
155
+ return None
156
+
157
+
97
158
  def migrate(db, migration_folder: str = "migrations", delimiter: str = ";") -> list[str]:
98
159
  """Run all pending migrations.
99
160
 
@@ -127,6 +188,13 @@ def migrate(db, migration_folder: str = "migrations", delimiter: str = ";") -> l
127
188
  try:
128
189
  db.start_transaction()
129
190
  for stmt in statements:
191
+ # Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
192
+ # Pre-check the system catalogue so duplicate columns are
193
+ # silently skipped instead of raising an error.
194
+ skip_reason = _should_skip_for_firebird(db, stmt)
195
+ if skip_reason:
196
+ logger.info(f"Migration {sql_file.name}: {skip_reason}")
197
+ continue
130
198
  db.execute(stmt)
131
199
 
132
200
  # Record as passed
@@ -483,7 +483,7 @@ class Queue:
483
483
 
484
484
  if job_id is not None:
485
485
  # Consume a specific job by ID
486
- job = self._pop_by_id(topic, job_id)
486
+ job = self.pop_by_id(topic, job_id)
487
487
  if job is not None:
488
488
  yield job
489
489
  return
@@ -495,7 +495,7 @@ class Queue:
495
495
  break
496
496
  yield job
497
497
 
498
- def _pop_by_id(self, topic: str, job_id: str) -> Job | None:
498
+ def pop_by_id(self, topic: str, job_id: str) -> Job | None:
499
499
  """Pop a specific job by ID from the queue."""
500
500
  if hasattr(self._backend, '_db'):
501
501
  # SQLite backend — query by ID
File without changes
File without changes