tina4-python 3.10.0__tar.gz → 3.10.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.
- {tina4_python-3.10.0 → tina4_python-3.10.2}/PKG-INFO +1 -1
- {tina4_python-3.10.0 → tina4_python-3.10.2}/pyproject.toml +1 -1
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/router.py +41 -18
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/server.py +94 -31
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/connection.py +7 -4
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/firebird.py +50 -26
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/frond/engine.py +74 -14
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/migration/runner.py +57 -6
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/orm/model.py +17 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/.gitignore +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/README.md +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/Testing.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/events.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/request.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/core/response.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/dev_reload.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -180,10 +180,12 @@ class Router:
|
|
|
180
180
|
if cls._group_prefix:
|
|
181
181
|
path = cls._group_prefix + path
|
|
182
182
|
|
|
183
|
-
# Merge group middleware with route-level middleware
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
183
|
+
# Merge group middleware with route-level middleware and handler-level middleware
|
|
184
|
+
handler_mw = getattr(handler, "_middleware", [])
|
|
185
|
+
route_mw = options.get("middleware", [])
|
|
186
|
+
combined_mw = list(cls._group_middleware) + list(handler_mw) + list(route_mw)
|
|
187
|
+
if combined_mw:
|
|
188
|
+
options["middleware"] = combined_mw
|
|
187
189
|
|
|
188
190
|
pattern, param_names = _compile_pattern(path)
|
|
189
191
|
|
|
@@ -251,8 +253,14 @@ def _compile_pattern(path: str) -> tuple[re.Pattern, list[str]]:
|
|
|
251
253
|
param_names = []
|
|
252
254
|
regex_parts = []
|
|
253
255
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
+
segments = path.strip("/").split("/")
|
|
257
|
+
for i, segment in enumerate(segments):
|
|
258
|
+
if segment == "*":
|
|
259
|
+
# Wildcard: matches the rest of the path (greedy)
|
|
260
|
+
param_names.append("wildcard")
|
|
261
|
+
regex_parts.append("(.+)")
|
|
262
|
+
break # Nothing can follow a wildcard
|
|
263
|
+
elif segment.startswith("{") and segment.endswith("}"):
|
|
256
264
|
inner = segment[1:-1]
|
|
257
265
|
if ":" in inner:
|
|
258
266
|
name, type_hint = inner.split(":", 1)
|
|
@@ -277,51 +285,53 @@ def _compile_pattern(path: str) -> tuple[re.Pattern, list[str]]:
|
|
|
277
285
|
|
|
278
286
|
# Decorator functions — the public API
|
|
279
287
|
|
|
288
|
+
def _register_route(method: str, path: str, fn, **options):
|
|
289
|
+
"""Common registration logic that preserves handler attributes on the returned ref."""
|
|
290
|
+
ref = Router.add(method, path, fn, **options)
|
|
291
|
+
# Propagate handler attributes to the wrapper so stacked decorators still work
|
|
292
|
+
fn._route_ref = ref
|
|
293
|
+
return fn
|
|
294
|
+
|
|
295
|
+
|
|
280
296
|
def get(path: str, **options):
|
|
281
297
|
"""Register a GET route."""
|
|
282
298
|
def decorator(fn):
|
|
283
|
-
|
|
284
|
-
return fn
|
|
299
|
+
return _register_route("GET", path, fn, **options)
|
|
285
300
|
return decorator
|
|
286
301
|
|
|
287
302
|
|
|
288
303
|
def post(path: str, **options):
|
|
289
304
|
"""Register a POST route."""
|
|
290
305
|
def decorator(fn):
|
|
291
|
-
|
|
292
|
-
return fn
|
|
306
|
+
return _register_route("POST", path, fn, **options)
|
|
293
307
|
return decorator
|
|
294
308
|
|
|
295
309
|
|
|
296
310
|
def put(path: str, **options):
|
|
297
311
|
"""Register a PUT route."""
|
|
298
312
|
def decorator(fn):
|
|
299
|
-
|
|
300
|
-
return fn
|
|
313
|
+
return _register_route("PUT", path, fn, **options)
|
|
301
314
|
return decorator
|
|
302
315
|
|
|
303
316
|
|
|
304
317
|
def patch(path: str, **options):
|
|
305
318
|
"""Register a PATCH route."""
|
|
306
319
|
def decorator(fn):
|
|
307
|
-
|
|
308
|
-
return fn
|
|
320
|
+
return _register_route("PATCH", path, fn, **options)
|
|
309
321
|
return decorator
|
|
310
322
|
|
|
311
323
|
|
|
312
324
|
def delete(path: str, **options):
|
|
313
325
|
"""Register a DELETE route."""
|
|
314
326
|
def decorator(fn):
|
|
315
|
-
|
|
316
|
-
return fn
|
|
327
|
+
return _register_route("DELETE", path, fn, **options)
|
|
317
328
|
return decorator
|
|
318
329
|
|
|
319
330
|
|
|
320
331
|
def any_method(path: str, **options):
|
|
321
332
|
"""Register a route for any HTTP method."""
|
|
322
333
|
def decorator(fn):
|
|
323
|
-
|
|
324
|
-
return fn
|
|
334
|
+
return _register_route("ANY", path, fn, **options)
|
|
325
335
|
return decorator
|
|
326
336
|
|
|
327
337
|
# Alias — @any() is the standard name across all Tina4 frameworks
|
|
@@ -352,6 +362,10 @@ def noauth():
|
|
|
352
362
|
"""Make a write route (POST/PUT/PATCH/DELETE) public — no auth required."""
|
|
353
363
|
def decorator(fn):
|
|
354
364
|
fn._noauth = True
|
|
365
|
+
# If route was already registered (decorator applied after @get/@post),
|
|
366
|
+
# update the route dict directly.
|
|
367
|
+
if hasattr(fn, "_route_ref"):
|
|
368
|
+
fn._route_ref._route["auth_required"] = False
|
|
355
369
|
return fn
|
|
356
370
|
return decorator
|
|
357
371
|
|
|
@@ -360,6 +374,10 @@ def secured():
|
|
|
360
374
|
"""Require auth on a GET route (which is public by default)."""
|
|
361
375
|
def decorator(fn):
|
|
362
376
|
fn._secured = True
|
|
377
|
+
# If route was already registered (decorator applied after @get/@post),
|
|
378
|
+
# update the route dict directly.
|
|
379
|
+
if hasattr(fn, "_route_ref"):
|
|
380
|
+
fn._route_ref._route["auth_required"] = True
|
|
363
381
|
return fn
|
|
364
382
|
return decorator
|
|
365
383
|
|
|
@@ -370,6 +388,11 @@ def middleware(*middleware_classes):
|
|
|
370
388
|
"""Attach middleware classes to a route handler."""
|
|
371
389
|
def decorator(fn):
|
|
372
390
|
fn._middleware = list(middleware_classes)
|
|
391
|
+
# If route was already registered (decorator applied after @get/@post),
|
|
392
|
+
# update the route dict directly.
|
|
393
|
+
if hasattr(fn, "_route_ref"):
|
|
394
|
+
existing = fn._route_ref._route.get("middleware", [])
|
|
395
|
+
fn._route_ref._route["middleware"] = list(middleware_classes) + existing
|
|
373
396
|
return fn
|
|
374
397
|
return decorator
|
|
375
398
|
|
|
@@ -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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
if
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
29
|
-
import fdb
|
|
30
|
-
except ImportError:
|
|
42
|
+
if _driver is None:
|
|
31
43
|
raise ImportError(
|
|
32
|
-
"
|
|
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
|
-
|
|
46
|
-
host
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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["
|
|
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["
|
|
228
|
-
"type": type_map.get(r.get("
|
|
229
|
-
"nullable": r.get("
|
|
230
|
-
"default": r.get("
|
|
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
|
|
@@ -217,21 +217,52 @@ def _resolve(expr: str, context: dict):
|
|
|
217
217
|
for part in parts:
|
|
218
218
|
if part.startswith("[") and part.endswith("]"):
|
|
219
219
|
idx = part[1:-1].strip("'\"")
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
220
|
+
# Slice syntax: value[1:5], value[:10], value[3:]
|
|
221
|
+
if ":" in idx:
|
|
222
|
+
slice_parts = idx.split(":", 1)
|
|
223
|
+
s_start = int(slice_parts[0]) if slice_parts[0].strip() else None
|
|
224
|
+
s_end = int(slice_parts[1]) if slice_parts[1].strip() else None
|
|
225
|
+
try:
|
|
226
|
+
value = value[s_start:s_end]
|
|
227
|
+
except (TypeError, IndexError):
|
|
228
|
+
return None
|
|
229
|
+
else:
|
|
230
|
+
try:
|
|
231
|
+
idx = int(idx)
|
|
232
|
+
except ValueError:
|
|
233
|
+
pass
|
|
234
|
+
try:
|
|
235
|
+
value = value[idx]
|
|
236
|
+
except (KeyError, IndexError, TypeError):
|
|
237
|
+
return None
|
|
233
238
|
else:
|
|
234
|
-
|
|
239
|
+
# Check if this part is a method call: name(args)
|
|
240
|
+
call_match = re.match(r"^(\w+)\s*\((.*)?\)$", part, re.DOTALL)
|
|
241
|
+
if call_match:
|
|
242
|
+
method_name = call_match.group(1)
|
|
243
|
+
raw_args = call_match.group(2) or ""
|
|
244
|
+
# Resolve the callable from the current value
|
|
245
|
+
if isinstance(value, dict):
|
|
246
|
+
fn = value.get(method_name)
|
|
247
|
+
elif hasattr(value, method_name):
|
|
248
|
+
fn = getattr(value, method_name)
|
|
249
|
+
else:
|
|
250
|
+
return None
|
|
251
|
+
if callable(fn):
|
|
252
|
+
if raw_args.strip():
|
|
253
|
+
args = [_eval_expr(a.strip(), context) for a in _split_args(raw_args)]
|
|
254
|
+
else:
|
|
255
|
+
args = []
|
|
256
|
+
value = fn(*args)
|
|
257
|
+
else:
|
|
258
|
+
return None
|
|
259
|
+
elif isinstance(value, dict):
|
|
260
|
+
value = value.get(part)
|
|
261
|
+
elif hasattr(value, part):
|
|
262
|
+
attr = getattr(value, part)
|
|
263
|
+
value = attr() if callable(attr) else attr
|
|
264
|
+
else:
|
|
265
|
+
return None
|
|
235
266
|
|
|
236
267
|
if value is None:
|
|
237
268
|
return None
|
|
@@ -239,6 +270,35 @@ def _resolve(expr: str, context: dict):
|
|
|
239
270
|
return value
|
|
240
271
|
|
|
241
272
|
|
|
273
|
+
def _split_args(raw: str) -> list[str]:
|
|
274
|
+
"""Split comma-separated arguments respecting quotes and nested parens."""
|
|
275
|
+
parts = []
|
|
276
|
+
current = ""
|
|
277
|
+
in_q = None
|
|
278
|
+
depth = 0
|
|
279
|
+
for ch in raw:
|
|
280
|
+
if ch in ('"', "'") and not in_q:
|
|
281
|
+
in_q = ch
|
|
282
|
+
current += ch
|
|
283
|
+
elif ch == in_q:
|
|
284
|
+
in_q = None
|
|
285
|
+
current += ch
|
|
286
|
+
elif ch == "(" and not in_q:
|
|
287
|
+
depth += 1
|
|
288
|
+
current += ch
|
|
289
|
+
elif ch == ")" and not in_q:
|
|
290
|
+
depth -= 1
|
|
291
|
+
current += ch
|
|
292
|
+
elif ch == "," and not in_q and depth == 0:
|
|
293
|
+
parts.append(current.strip())
|
|
294
|
+
current = ""
|
|
295
|
+
else:
|
|
296
|
+
current += ch
|
|
297
|
+
if current.strip():
|
|
298
|
+
parts.append(current.strip())
|
|
299
|
+
return parts
|
|
300
|
+
|
|
301
|
+
|
|
242
302
|
def _eval_expr(expr: str, context: dict):
|
|
243
303
|
"""Evaluate a full expression (with ~, ternary, ??, comparisons)."""
|
|
244
304
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
@@ -43,6 +43,22 @@ def orm_bind(db, name: str = None):
|
|
|
43
43
|
_databases[name] = db
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def snake_to_camel(name: str) -> str:
|
|
47
|
+
"""Convert snake_case to camelCase: 'first_name' -> 'firstName'."""
|
|
48
|
+
parts = name.split("_")
|
|
49
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def camel_to_snake(name: str) -> str:
|
|
53
|
+
"""Convert camelCase to snake_case: 'firstName' -> 'first_name'."""
|
|
54
|
+
result = []
|
|
55
|
+
for c in name:
|
|
56
|
+
if c.isupper() and result:
|
|
57
|
+
result.append("_")
|
|
58
|
+
result.append(c.lower())
|
|
59
|
+
return "".join(result)
|
|
60
|
+
|
|
61
|
+
|
|
46
62
|
class ORMMeta(type):
|
|
47
63
|
"""Metaclass that collects Field definitions and relationship descriptors."""
|
|
48
64
|
|
|
@@ -79,6 +95,7 @@ class ORM(metaclass=ORMMeta):
|
|
|
79
95
|
table_name: str = ""
|
|
80
96
|
soft_delete: bool = False # Set True to enable soft delete
|
|
81
97
|
field_mapping: dict[str, str] = {} # {"python_attribute": "db_column"}
|
|
98
|
+
auto_map: bool = False # No-op in Python (snake_case matches DB); exists for cross-language parity
|
|
82
99
|
_db: str | object | None = None # Per-model database override
|
|
83
100
|
_fields: dict[str, Field] = {}
|
|
84
101
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/public/swagger/oauth2-redirect.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/session_handlers/mongodb_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/templates/docker/distroless/Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/af/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/en/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/es/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.10.0 → tina4_python-3.10.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|