tina4-python 3.11.36__tar.gz → 3.12.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.
- {tina4_python-3.11.36 → tina4_python-3.12.1}/PKG-INFO +1 -1
- {tina4_python-3.11.36 → tina4_python-3.12.1}/pyproject.toml +1 -1
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/__init__.py +1 -1
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/auth/__init__.py +7 -7
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/cli/__init__.py +53 -5
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/middleware.py +2 -2
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/server.py +183 -16
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/connection.py +6 -6
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/postgres.py +16 -2
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/dev_admin/__init__.py +19 -19
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/dotenv/__init__.py +1 -1
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/frond/engine.py +1 -1
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/mcp/__init__.py +1 -1
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/messenger/__init__.py +9 -9
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/orm/model.py +1011 -1011
- tina4_python-3.12.1/tina4_python/public/js/frond.js +600 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/js/frond.min.js +1 -1
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/swagger/__init__.py +3 -3
- {tina4_python-3.11.36 → tina4_python-3.12.1}/.gitignore +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/README.md +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/Testing.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/events.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/request.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/response.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/core/router.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/docs.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.11.36 → tina4_python-3.12.1}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -60,7 +60,7 @@ class Auth:
|
|
|
60
60
|
algorithm: JWT algorithm (default HS256).
|
|
61
61
|
expires_in: Token lifetime in seconds (default 3600).
|
|
62
62
|
"""
|
|
63
|
-
self.secret = secret or os.environ.get("
|
|
63
|
+
self.secret = secret or os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
64
64
|
self.algorithm = algorithm
|
|
65
65
|
self.expires_in = expires_in or int(
|
|
66
66
|
os.environ.get("TINA4_TOKEN_LIMIT", "60")
|
|
@@ -161,28 +161,28 @@ class Auth:
|
|
|
161
161
|
@classmethod
|
|
162
162
|
def get_token_static(cls, payload: dict, expires_in: int = 60) -> str:
|
|
163
163
|
"""Create a JWT without instantiating Auth — reads SECRET from env."""
|
|
164
|
-
secret = os.environ.get("
|
|
164
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
165
165
|
auth = cls(secret=secret, expires_in=expires_in)
|
|
166
166
|
return auth.get_token(payload)
|
|
167
167
|
|
|
168
168
|
@classmethod
|
|
169
169
|
def valid_token_static(cls, token: str) -> bool:
|
|
170
170
|
"""Validate a JWT without instantiating Auth — reads SECRET from env."""
|
|
171
|
-
secret = os.environ.get("
|
|
171
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
172
172
|
auth = cls(secret=secret)
|
|
173
173
|
return auth.valid_token(token)
|
|
174
174
|
|
|
175
175
|
@classmethod
|
|
176
176
|
def get_payload_static(cls, token: str) -> dict | None:
|
|
177
177
|
"""Decode payload (no validation) without instantiating Auth."""
|
|
178
|
-
secret = os.environ.get("
|
|
178
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
179
179
|
auth = cls(secret=secret)
|
|
180
180
|
return auth.get_payload(token)
|
|
181
181
|
|
|
182
182
|
@classmethod
|
|
183
183
|
def refresh_token_static(cls, token: str, expires_in: int = 60) -> str | None:
|
|
184
184
|
"""Refresh a JWT without instantiating Auth — reads SECRET from env."""
|
|
185
|
-
secret = os.environ.get("
|
|
185
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
186
186
|
auth = cls(secret=secret, expires_in=expires_in)
|
|
187
187
|
return auth.refresh_token(token)
|
|
188
188
|
|
|
@@ -193,7 +193,7 @@ class Auth:
|
|
|
193
193
|
Reads SECRET from env. Checks: Bearer JWT, Bearer API key, Basic auth.
|
|
194
194
|
Returns payload dict on success, None on failure.
|
|
195
195
|
"""
|
|
196
|
-
secret = os.environ.get("
|
|
196
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
197
197
|
auth = cls(secret=secret)
|
|
198
198
|
return auth.authenticate_request(headers)
|
|
199
199
|
|
|
@@ -246,7 +246,7 @@ class Auth:
|
|
|
246
246
|
Returns: True if the provided key matches.
|
|
247
247
|
"""
|
|
248
248
|
if expected is None:
|
|
249
|
-
expected = os.environ.get("TINA4_API_KEY",
|
|
249
|
+
expected = os.environ.get("TINA4_API_KEY", "")
|
|
250
250
|
if not expected:
|
|
251
251
|
return False
|
|
252
252
|
return hmac.compare_digest(provided, expected)
|
|
@@ -137,6 +137,7 @@ def main():
|
|
|
137
137
|
"migrate:create": _migrate_create,
|
|
138
138
|
"migrate:rollback": _migrate_rollback,
|
|
139
139
|
"migrate:status": _migrate_status,
|
|
140
|
+
"env-migrate": _env_migrate,
|
|
140
141
|
"seed": _seed,
|
|
141
142
|
"routes": _routes,
|
|
142
143
|
"test": _test,
|
|
@@ -155,6 +156,52 @@ def main():
|
|
|
155
156
|
_help([])
|
|
156
157
|
|
|
157
158
|
|
|
159
|
+
def _env_migrate(args):
|
|
160
|
+
"""Rewrite a .env file in place, renaming pre-3.12 names to TINA4_ form.
|
|
161
|
+
|
|
162
|
+
Usage: tina4python env-migrate [path] (default path: .env)
|
|
163
|
+
|
|
164
|
+
Backs the original up to <path>.bak before rewriting. Prints a diff of
|
|
165
|
+
each rename. Idempotent — running twice is a no-op on the second run.
|
|
166
|
+
"""
|
|
167
|
+
from tina4_python.core.server import _LEGACY_ENV_VARS
|
|
168
|
+
target = Path(args[0]) if args else Path(".env")
|
|
169
|
+
if not target.is_file():
|
|
170
|
+
print(f" no .env at {target}")
|
|
171
|
+
return
|
|
172
|
+
text = target.read_text(encoding="utf-8")
|
|
173
|
+
backup = target.with_suffix(target.suffix + ".bak")
|
|
174
|
+
backup.write_text(text, encoding="utf-8")
|
|
175
|
+
print(f" backup written: {backup}")
|
|
176
|
+
|
|
177
|
+
renamed = 0
|
|
178
|
+
new_lines = []
|
|
179
|
+
for line in text.splitlines(keepends=True):
|
|
180
|
+
stripped = line.lstrip()
|
|
181
|
+
if not stripped or stripped.startswith("#"):
|
|
182
|
+
new_lines.append(line); continue
|
|
183
|
+
if "=" not in stripped:
|
|
184
|
+
new_lines.append(line); continue
|
|
185
|
+
key = stripped.split("=", 1)[0].strip()
|
|
186
|
+
if key in _LEGACY_ENV_VARS:
|
|
187
|
+
new_key = _LEGACY_ENV_VARS[key]
|
|
188
|
+
new_line = line.replace(key, new_key, 1)
|
|
189
|
+
print(f" {key:<28} → {new_key}")
|
|
190
|
+
new_lines.append(new_line)
|
|
191
|
+
renamed += 1
|
|
192
|
+
else:
|
|
193
|
+
new_lines.append(line)
|
|
194
|
+
|
|
195
|
+
if renamed == 0:
|
|
196
|
+
print(" nothing to rename — your .env is already on the new convention")
|
|
197
|
+
backup.unlink() # don't leave a noise backup
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
target.write_text("".join(new_lines), encoding="utf-8")
|
|
201
|
+
print(f"\n done: {renamed} rename(s) applied to {target}")
|
|
202
|
+
print(f" original kept at {backup} (delete once you've verified)")
|
|
203
|
+
|
|
204
|
+
|
|
158
205
|
def _help(args=None):
|
|
159
206
|
print("""
|
|
160
207
|
Tina4 Python — CLI
|
|
@@ -168,6 +215,7 @@ Commands:
|
|
|
168
215
|
migrate:create <desc> Create a new migration file
|
|
169
216
|
migrate:rollback Rollback last migration batch
|
|
170
217
|
migrate:status Show migration status
|
|
218
|
+
env-migrate [path] Rewrite .env to TINA4_-prefixed names (v3.12 migration)
|
|
171
219
|
seed Run database seeders
|
|
172
220
|
routes List all registered routes
|
|
173
221
|
test Run test suite
|
|
@@ -212,7 +260,7 @@ def _console(args=None):
|
|
|
212
260
|
|
|
213
261
|
# Try to connect database from DATABASE_URL
|
|
214
262
|
db = None
|
|
215
|
-
db_url = os.environ.get("
|
|
263
|
+
db_url = os.environ.get("TINA4_DATABASE_URL")
|
|
216
264
|
if db_url:
|
|
217
265
|
try:
|
|
218
266
|
db = Database(db_url)
|
|
@@ -404,7 +452,7 @@ def _migrate(args):
|
|
|
404
452
|
from tina4_python.database import Database
|
|
405
453
|
from tina4_python.migration import Migration
|
|
406
454
|
|
|
407
|
-
db_url = os.environ.get("
|
|
455
|
+
db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
|
|
408
456
|
db = Database(db_url)
|
|
409
457
|
mig_dir = args[0] if args else "migrations"
|
|
410
458
|
ran = Migration(db, mig_dir).migrate()
|
|
@@ -434,7 +482,7 @@ def _migrate_rollback(args):
|
|
|
434
482
|
from tina4_python.database import Database
|
|
435
483
|
from tina4_python.migration import Migration
|
|
436
484
|
|
|
437
|
-
db_url = os.environ.get("
|
|
485
|
+
db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
|
|
438
486
|
db = Database(db_url)
|
|
439
487
|
mig_dir = args[0] if args else "migrations"
|
|
440
488
|
rolled = Migration(db, mig_dir).rollback()
|
|
@@ -453,7 +501,7 @@ def _migrate_status(args):
|
|
|
453
501
|
from tina4_python.database import Database
|
|
454
502
|
from tina4_python.migration import Migration
|
|
455
503
|
|
|
456
|
-
db_url = os.environ.get("
|
|
504
|
+
db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
|
|
457
505
|
db = Database(db_url)
|
|
458
506
|
result = Migration(db, args[0] if args else "migrations").status()
|
|
459
507
|
completed, pending = result["completed"], result["pending"]
|
|
@@ -489,7 +537,7 @@ def _seed(args):
|
|
|
489
537
|
import importlib.util
|
|
490
538
|
from tina4_python.database import Database
|
|
491
539
|
|
|
492
|
-
db_url = os.environ.get("
|
|
540
|
+
db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
|
|
493
541
|
db = Database(db_url)
|
|
494
542
|
sys.path.insert(0, str(Path.cwd()))
|
|
495
543
|
|
|
@@ -287,7 +287,7 @@ class CsrfMiddleware:
|
|
|
287
287
|
bearer_token = auth_header[7:].strip()
|
|
288
288
|
if bearer_token:
|
|
289
289
|
from tina4_python.auth import Auth as _CsrfAuth
|
|
290
|
-
secret = os.environ.get("
|
|
290
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
291
291
|
auth = _CsrfAuth(secret=secret)
|
|
292
292
|
if auth.valid_token(bearer_token):
|
|
293
293
|
return request, response
|
|
@@ -326,7 +326,7 @@ class CsrfMiddleware:
|
|
|
326
326
|
|
|
327
327
|
# Validate the token
|
|
328
328
|
from tina4_python.auth import Auth as _CsrfAuth
|
|
329
|
-
secret = os.environ.get("
|
|
329
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
330
330
|
auth = _CsrfAuth(secret=secret)
|
|
331
331
|
if not auth.valid_token(token):
|
|
332
332
|
return request, response.error(
|
|
@@ -193,20 +193,93 @@ def _render_error_page(status_code: int, path: str, request_id: str, error_messa
|
|
|
193
193
|
_template_cache: dict[str, str] | None = None
|
|
194
194
|
|
|
195
195
|
|
|
196
|
+
# Auto-routing scans this single subdirectory of src/templates/. Only files
|
|
197
|
+
# in src/templates/pages/ become URLs — everything else (partials, layouts,
|
|
198
|
+
# base.twig, errors, components, macros) is never URL-exposed and remains
|
|
199
|
+
# renderable only via {% include %} / {% extends %} / response.render().
|
|
200
|
+
#
|
|
201
|
+
# Convention adapted from Next.js' pages/ directory and Nuxt's pages/ folder.
|
|
202
|
+
# Explicit, secure by default, no skip lists to maintain.
|
|
203
|
+
_TEMPLATE_PAGES_DIR = "pages"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_dev_mode() -> bool:
|
|
207
|
+
"""True when ``TINA4_DEBUG`` is one of the truthy values (true|1|yes).
|
|
208
|
+
|
|
209
|
+
Centralised so every dev-mode gate (landing page, dev toolbar, error
|
|
210
|
+
overlay, dev admin) reads the same flag the same way.
|
|
211
|
+
"""
|
|
212
|
+
return os.environ.get("TINA4_DEBUG", "false").strip().lower() in ("true", "1", "yes")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# RFC 7231 / RFC 9110 status reason phrases. We use this to write a correct
|
|
216
|
+
# HTTP status line in the dev server's HTTP/1.1 → ASGI bridge — previously
|
|
217
|
+
# the bridge wrote "HTTP/1.1 404 OK" regardless of code, which is malformed.
|
|
218
|
+
_HTTP_REASON_PHRASES: dict[int, str] = {
|
|
219
|
+
100: "Continue", 101: "Switching Protocols",
|
|
220
|
+
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content",
|
|
221
|
+
206: "Partial Content",
|
|
222
|
+
301: "Moved Permanently", 302: "Found", 303: "See Other",
|
|
223
|
+
304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect",
|
|
224
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
225
|
+
404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
|
|
226
|
+
409: "Conflict", 410: "Gone", 413: "Content Too Large",
|
|
227
|
+
415: "Unsupported Media Type", 422: "Unprocessable Content",
|
|
228
|
+
429: "Too Many Requests",
|
|
229
|
+
500: "Internal Server Error", 501: "Not Implemented",
|
|
230
|
+
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _http_reason(status: int) -> str:
|
|
235
|
+
"""Return the canonical HTTP reason phrase for ``status``.
|
|
236
|
+
|
|
237
|
+
Falls back to a sensible label when an exotic status is used. Never
|
|
238
|
+
returns an empty string — the HTTP/1.1 status line requires a phrase.
|
|
239
|
+
"""
|
|
240
|
+
return _HTTP_REASON_PHRASES.get(int(status), "OK" if 200 <= status < 300 else "Error")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _template_auto_routing_enabled() -> bool:
|
|
244
|
+
"""Honour TINA4_TEMPLATE_ROUTING=off|false|0|no as an explicit kill switch.
|
|
245
|
+
|
|
246
|
+
Default: enabled. Drop a file in src/templates/pages/ and it serves at
|
|
247
|
+
the matching URL — the zero-config Tina4 convention. Operators who want
|
|
248
|
+
explicit-only routing can set TINA4_TEMPLATE_ROUTING=off and every URL
|
|
249
|
+
must be registered via @get / @post (or be a static file).
|
|
250
|
+
"""
|
|
251
|
+
val = os.environ.get("TINA4_TEMPLATE_ROUTING", "on").strip().lower()
|
|
252
|
+
return val not in ("off", "false", "0", "no", "disabled")
|
|
253
|
+
|
|
254
|
+
|
|
196
255
|
def _resolve_template(path: str) -> str | None:
|
|
197
|
-
"""Resolve a URL path to a template file in src/templates/.
|
|
256
|
+
"""Resolve a URL path to a template file in src/templates/pages/.
|
|
257
|
+
|
|
258
|
+
Only files inside ``src/templates/pages/`` auto-route from a URL.
|
|
259
|
+
Anything in ``src/templates/`` outside ``pages/`` (partials, layouts,
|
|
260
|
+
base.twig, errors, components) is never served standalone.
|
|
261
|
+
|
|
198
262
|
Dev mode: checks filesystem every time for live changes.
|
|
199
263
|
Production: uses a cached lookup built once at startup.
|
|
264
|
+
|
|
265
|
+
The whole feature can be turned off with ``TINA4_TEMPLATE_ROUTING=off``.
|
|
200
266
|
"""
|
|
267
|
+
if not _template_auto_routing_enabled():
|
|
268
|
+
return None
|
|
269
|
+
|
|
201
270
|
clean_path = path.strip("/") or "index"
|
|
202
271
|
is_dev = os.environ.get("TINA4_DEBUG", "false").lower() in ("true", "1", "yes")
|
|
203
272
|
|
|
204
273
|
if is_dev:
|
|
205
|
-
|
|
274
|
+
# Skip underscore-prefixed files even within pages/ — they're private
|
|
275
|
+
# by Hugo/Jekyll convention (helpers, fragments) and shouldn't auto-serve.
|
|
276
|
+
if any(seg.startswith("_") for seg in clean_path.split("/")):
|
|
277
|
+
return None
|
|
278
|
+
pages_dir = Path("src/templates") / _TEMPLATE_PAGES_DIR
|
|
206
279
|
for ext in (".twig", ".html"):
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
return
|
|
280
|
+
candidate_rel = f"{_TEMPLATE_PAGES_DIR}/{clean_path}{ext}"
|
|
281
|
+
if (pages_dir / (clean_path + ext)).is_file():
|
|
282
|
+
return candidate_rel
|
|
210
283
|
return None
|
|
211
284
|
|
|
212
285
|
global _template_cache
|
|
@@ -216,17 +289,25 @@ def _resolve_template(path: str) -> str | None:
|
|
|
216
289
|
|
|
217
290
|
|
|
218
291
|
def _build_template_cache() -> None:
|
|
219
|
-
"""Scan src/templates/ once and build url_path -> template_file lookup.
|
|
292
|
+
"""Scan src/templates/pages/ once and build url_path -> template_file lookup.
|
|
293
|
+
Only files under ``pages/`` are eligible — partials, layouts, base.twig,
|
|
294
|
+
errors etc remain renderable via explicit response.render() but never
|
|
295
|
+
auto-serve from a URL.
|
|
296
|
+
"""
|
|
220
297
|
global _template_cache
|
|
221
298
|
_template_cache = {}
|
|
222
|
-
|
|
223
|
-
if not
|
|
299
|
+
pages_dir = Path("src/templates") / _TEMPLATE_PAGES_DIR
|
|
300
|
+
if not pages_dir.is_dir():
|
|
224
301
|
return
|
|
225
|
-
for f in
|
|
302
|
+
for f in pages_dir.rglob("*"):
|
|
226
303
|
if not f.is_file() or f.suffix not in (".twig", ".html"):
|
|
227
304
|
continue
|
|
228
|
-
|
|
229
|
-
|
|
305
|
+
# Skip private files even within pages/ (e.g. pages/_helper.twig)
|
|
306
|
+
rel_inside_pages = f.relative_to(pages_dir)
|
|
307
|
+
if any(p.startswith("_") for p in rel_inside_pages.parts):
|
|
308
|
+
continue
|
|
309
|
+
rel = str(f.relative_to(Path("src/templates"))).replace("\\", "/")
|
|
310
|
+
url_path = str(rel_inside_pages).replace("\\", "/").rsplit(".", 1)[0]
|
|
230
311
|
if url_path not in _template_cache:
|
|
231
312
|
_template_cache[url_path] = rel
|
|
232
313
|
|
|
@@ -920,7 +1001,7 @@ def _check_auth(request: Request, response: Response, route: dict) -> bool:
|
|
|
920
1001
|
if not route.get("auth_required"):
|
|
921
1002
|
return False
|
|
922
1003
|
_auth_header = request.headers.get("authorization", "")
|
|
923
|
-
_api_key = os.environ.get("TINA4_API_KEY",
|
|
1004
|
+
_api_key = os.environ.get("TINA4_API_KEY", "")
|
|
924
1005
|
_auth_ok = False
|
|
925
1006
|
if _auth_header and _auth_header.startswith("Bearer "):
|
|
926
1007
|
_token = _auth_header[7:]
|
|
@@ -1076,7 +1157,19 @@ def _handle_route_error(
|
|
|
1076
1157
|
|
|
1077
1158
|
|
|
1078
1159
|
def _handle_no_route(request: Request, response: Response, request_id: str) -> Response:
|
|
1079
|
-
"""Serve static files, templates, landing page, or 404.
|
|
1160
|
+
"""Serve static files, templates, landing page, or 404.
|
|
1161
|
+
|
|
1162
|
+
Lookup order at any URL with no registered route:
|
|
1163
|
+
1. Static file (public/, src/public/, framework public/, with /
|
|
1164
|
+
resolving to index.html so SPAs Just Work)
|
|
1165
|
+
2. Auto-routed template from src/templates/pages/ (gated by
|
|
1166
|
+
TINA4_TEMPLATE_ROUTING)
|
|
1167
|
+
3. Framework landing page — only at "/", and only in dev
|
|
1168
|
+
(``TINA4_DEBUG=true``). Production never shows it, so the
|
|
1169
|
+
framework version, dev-admin link, and gallery never leak
|
|
1170
|
+
to real users.
|
|
1171
|
+
4. 404
|
|
1172
|
+
"""
|
|
1080
1173
|
static = _try_static(request.path)
|
|
1081
1174
|
if static:
|
|
1082
1175
|
return static
|
|
@@ -1085,7 +1178,7 @@ def _handle_no_route(request: Request, response: Response, request_id: str) -> R
|
|
|
1085
1178
|
from tina4_python.core.response import get_frond
|
|
1086
1179
|
html = get_frond().render(tpl_file, {})
|
|
1087
1180
|
response.html(html)
|
|
1088
|
-
elif request.path == "/":
|
|
1181
|
+
elif request.path == "/" and _is_dev_mode():
|
|
1089
1182
|
response.html(_render_landing_page())
|
|
1090
1183
|
else:
|
|
1091
1184
|
html = _render_error_page(404, request.path, request_id)
|
|
@@ -1324,8 +1417,16 @@ def _try_static(path: str) -> Response | None:
|
|
|
1324
1417
|
2. public/ (simple, IDE-friendly)
|
|
1325
1418
|
3. src/public/ (nested convention)
|
|
1326
1419
|
4. tina4_python/public/ (framework built-in assets)
|
|
1420
|
+
|
|
1421
|
+
Index resolution: when ``path`` is ``/`` or ends with ``/``, the lookup
|
|
1422
|
+
appends ``index.html`` so a Vite/SPA build with ``src/public/index.html``
|
|
1423
|
+
serves at the matching URL — no custom ``@get("/")`` route needed.
|
|
1327
1424
|
"""
|
|
1328
1425
|
clean = path.lstrip("/")
|
|
1426
|
+
# Index resolution: '/' or '/foo/' -> append 'index.html' so SPA builds
|
|
1427
|
+
# in src/public/ Just Work without a custom root route.
|
|
1428
|
+
if clean == "" or clean.endswith("/"):
|
|
1429
|
+
clean = clean + "index.html"
|
|
1329
1430
|
custom = os.environ.get("TINA4_PUBLIC_DIR")
|
|
1330
1431
|
candidates = []
|
|
1331
1432
|
if custom:
|
|
@@ -1575,6 +1676,69 @@ def _print_banner(host: str, port: int, server_name: str = "asyncio", ai_port: i
|
|
|
1575
1676
|
print(banner)
|
|
1576
1677
|
|
|
1577
1678
|
|
|
1679
|
+
# Legacy env var names that v3.12 has retired. If any of these are set in
|
|
1680
|
+
# the environment we refuse to boot — silently ignoring them would cause
|
|
1681
|
+
# auth/db/mail to fall back to defaults with no warning. Each maps to its
|
|
1682
|
+
# new TINA4_-prefixed canonical name (or DROPPED for deleted features).
|
|
1683
|
+
_LEGACY_ENV_VARS: dict[str, str] = {
|
|
1684
|
+
"DATABASE_URL": "TINA4_DATABASE_URL",
|
|
1685
|
+
"DATABASE_USERNAME": "TINA4_DATABASE_USERNAME",
|
|
1686
|
+
"DATABASE_PASSWORD": "TINA4_DATABASE_PASSWORD",
|
|
1687
|
+
"DB_URL": "TINA4_DATABASE_URL",
|
|
1688
|
+
"SECRET": "TINA4_SECRET",
|
|
1689
|
+
"API_KEY": "TINA4_API_KEY",
|
|
1690
|
+
"JWT_ALGORITHM": "TINA4_JWT_ALGORITHM",
|
|
1691
|
+
"SMTP_HOST": "TINA4_MAIL_HOST",
|
|
1692
|
+
"SMTP_PORT": "TINA4_MAIL_PORT",
|
|
1693
|
+
"SMTP_USERNAME": "TINA4_MAIL_USERNAME",
|
|
1694
|
+
"SMTP_PASSWORD": "TINA4_MAIL_PASSWORD",
|
|
1695
|
+
"SMTP_FROM": "TINA4_MAIL_FROM",
|
|
1696
|
+
"SMTP_FROM_NAME": "TINA4_MAIL_FROM_NAME",
|
|
1697
|
+
"IMAP_HOST": "TINA4_MAIL_IMAP_HOST",
|
|
1698
|
+
"IMAP_PORT": "TINA4_MAIL_IMAP_PORT",
|
|
1699
|
+
"IMAP_USER": "TINA4_MAIL_IMAP_USERNAME",
|
|
1700
|
+
"IMAP_PASS": "TINA4_MAIL_IMAP_PASSWORD",
|
|
1701
|
+
"HOST_NAME": "TINA4_HOST_NAME",
|
|
1702
|
+
"SWAGGER_TITLE": "TINA4_SWAGGER_TITLE",
|
|
1703
|
+
"SWAGGER_DESCRIPTION": "TINA4_SWAGGER_DESCRIPTION",
|
|
1704
|
+
"SWAGGER_VERSION": "TINA4_SWAGGER_VERSION",
|
|
1705
|
+
"ORM_PLURAL_TABLE_NAMES": "TINA4_ORM_PLURAL_TABLE_NAMES",
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def _check_legacy_env_vars() -> None:
|
|
1710
|
+
"""Refuse to boot if pre-3.12 un-prefixed env vars are still set.
|
|
1711
|
+
|
|
1712
|
+
Tina4 v3.12 hard-renamed every framework-specific env var to use the
|
|
1713
|
+
``TINA4_`` prefix. Booting silently with a legacy ``DATABASE_URL`` or
|
|
1714
|
+
``SECRET`` would let auth, DB, or mail fall back to insecure defaults
|
|
1715
|
+
while the user thought their config was being read. Better to die
|
|
1716
|
+
loudly with a list of names to fix.
|
|
1717
|
+
|
|
1718
|
+
Bypass with ``TINA4_ALLOW_LEGACY_ENV=true`` in CI / migration scripts
|
|
1719
|
+
that genuinely need both names set during a transition window.
|
|
1720
|
+
"""
|
|
1721
|
+
if os.environ.get("TINA4_ALLOW_LEGACY_ENV", "").lower() in ("true", "1", "yes"):
|
|
1722
|
+
return
|
|
1723
|
+
found = sorted(name for name in _LEGACY_ENV_VARS if name in os.environ)
|
|
1724
|
+
if not found:
|
|
1725
|
+
return
|
|
1726
|
+
msg = ["", "─" * 72,
|
|
1727
|
+
"Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
|
|
1728
|
+
"Your environment still has these legacy names:",
|
|
1729
|
+
""]
|
|
1730
|
+
for old in found:
|
|
1731
|
+
new = _LEGACY_ENV_VARS[old]
|
|
1732
|
+
msg.append(f" {old:<28} → {new}")
|
|
1733
|
+
msg.extend(["",
|
|
1734
|
+
"Run `tina4 env-migrate` to rewrite your .env automatically,",
|
|
1735
|
+
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
1736
|
+
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
1737
|
+
"─" * 72, ""])
|
|
1738
|
+
print("\n".join(msg), file=sys.stderr)
|
|
1739
|
+
sys.exit(2)
|
|
1740
|
+
|
|
1741
|
+
|
|
1578
1742
|
def run(host: str | None = None, port: int | None = None, no_browser: bool = False, no_reload: bool = False):
|
|
1579
1743
|
"""Start the Tina4 dev server.
|
|
1580
1744
|
|
|
@@ -1590,6 +1754,9 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
1590
1754
|
global _start_time
|
|
1591
1755
|
_start_time = time.time()
|
|
1592
1756
|
|
|
1757
|
+
# Refuse to boot with v3.11 / v2 era un-prefixed env vars set.
|
|
1758
|
+
_check_legacy_env_vars()
|
|
1759
|
+
|
|
1593
1760
|
# ── Require tina4 CLI ─────────────────────────────────────────
|
|
1594
1761
|
# The framework must be launched via `tina4 serve`, not `python app.py`.
|
|
1595
1762
|
# The tina4 CLI passes --managed when spawning the server process.
|
|
@@ -1829,7 +1996,7 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
1829
1996
|
# Streaming mode — flush headers on first chunk, then write each chunk immediately
|
|
1830
1997
|
if not _headers_sent:
|
|
1831
1998
|
_headers_sent = True
|
|
1832
|
-
writer.write(f"HTTP/1.1 {resp_status}
|
|
1999
|
+
writer.write(f"HTTP/1.1 {resp_status} {_http_reason(resp_status)}\r\n".encode())
|
|
1833
2000
|
for name, value in resp_headers:
|
|
1834
2001
|
writer.write(name + b": " + value + b"\r\n")
|
|
1835
2002
|
writer.write(b"\r\n")
|
|
@@ -1849,7 +2016,7 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
1849
2016
|
|
|
1850
2017
|
# Write HTTP/1.1 response (only if headers weren't already sent by streaming)
|
|
1851
2018
|
if not _headers_sent:
|
|
1852
|
-
status_line = f"HTTP/1.1 {resp_status}
|
|
2019
|
+
status_line = f"HTTP/1.1 {resp_status} {_http_reason(resp_status)}\r\n"
|
|
1853
2020
|
writer.write(status_line.encode())
|
|
1854
2021
|
for name, value in resp_headers:
|
|
1855
2022
|
writer.write(name + b": " + value + b"\r\n")
|
|
@@ -140,10 +140,10 @@ class Database:
|
|
|
140
140
|
"""
|
|
141
141
|
|
|
142
142
|
def __init__(self, url: str = None, username: str = "", password: str = "", pool: int = 0, **kwargs):
|
|
143
|
-
self.url = url or os.environ.get("
|
|
143
|
+
self.url = url or os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/tina4.db")
|
|
144
144
|
# Priority: constructor params > env vars > empty
|
|
145
|
-
self.username = username or os.environ.get("
|
|
146
|
-
self.password = password or os.environ.get("
|
|
145
|
+
self.username = username or os.environ.get("TINA4_DATABASE_USERNAME", "")
|
|
146
|
+
self.password = password or os.environ.get("TINA4_DATABASE_PASSWORD", "")
|
|
147
147
|
self.pool_size = pool # 0 = single connection, N>0 = N pooled connections
|
|
148
148
|
self._connect_kwargs = kwargs # Extra kwargs passed through to adapter.connect()
|
|
149
149
|
self.last_error = None # Last execute() error message
|
|
@@ -671,7 +671,7 @@ class Database:
|
|
|
671
671
|
return Database(url, username=username, password=password, pool=pool)
|
|
672
672
|
|
|
673
673
|
@staticmethod
|
|
674
|
-
def from_env(env_key: str = "
|
|
674
|
+
def from_env(env_key: str = "TINA4_DATABASE_URL", pool: int = 0) -> "Database | None":
|
|
675
675
|
"""Construct a Database instance from environment variables.
|
|
676
676
|
|
|
677
677
|
Reads the connection URL from the named env var (default DATABASE_URL),
|
|
@@ -687,8 +687,8 @@ class Database:
|
|
|
687
687
|
url = os.environ.get(env_key)
|
|
688
688
|
if not url:
|
|
689
689
|
return None
|
|
690
|
-
username = os.environ.get("
|
|
691
|
-
password = os.environ.get("
|
|
690
|
+
username = os.environ.get("TINA4_DATABASE_USERNAME", "")
|
|
691
|
+
password = os.environ.get("TINA4_DATABASE_PASSWORD", "")
|
|
692
692
|
return Database(url, username=username, password=password, pool=pool)
|
|
693
693
|
|
|
694
694
|
# ── Adapter / pool inspection ─────────────────────────────────
|
|
@@ -74,16 +74,30 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
74
74
|
last_id = records[0]["id"]
|
|
75
75
|
|
|
76
76
|
if not has_returning:
|
|
77
|
-
# Try to get last inserted ID for INSERT statements
|
|
77
|
+
# Try to get last inserted ID for INSERT statements.
|
|
78
|
+
#
|
|
79
|
+
# Issue #38: ``SELECT lastval()`` raises on tables with no sequence
|
|
80
|
+
# (UUID, ULID, hash PKs etc.). The exception itself isn't fatal,
|
|
81
|
+
# but psycopg2 marks the whole transaction as aborted, so every
|
|
82
|
+
# subsequent statement on this connection fails with
|
|
83
|
+
# ``InFailedSqlTransaction`` — far away from the real cause and
|
|
84
|
+
# with ``last_error`` still ``None``.
|
|
85
|
+
#
|
|
86
|
+
# Fix: wrap the probe in a SAVEPOINT. If ``lastval()`` raises, we
|
|
87
|
+
# ROLLBACK TO SAVEPOINT and the outer transaction stays usable;
|
|
88
|
+
# ``last_id`` just stays ``None`` (same as before for non-INSERT
|
|
89
|
+
# statements). On success we RELEASE SAVEPOINT.
|
|
78
90
|
sql_upper = sql.strip().upper()
|
|
79
91
|
if sql_upper.startswith("INSERT"):
|
|
92
|
+
cursor.execute("SAVEPOINT _t4_lastval_probe")
|
|
80
93
|
try:
|
|
81
94
|
cursor.execute("SELECT lastval()")
|
|
82
95
|
row = cursor.fetchone()
|
|
83
96
|
if row:
|
|
84
97
|
last_id = list(row.values())[0]
|
|
98
|
+
cursor.execute("RELEASE SAVEPOINT _t4_lastval_probe")
|
|
85
99
|
except Exception:
|
|
86
|
-
|
|
100
|
+
cursor.execute("ROLLBACK TO SAVEPOINT _t4_lastval_probe")
|
|
87
101
|
|
|
88
102
|
affected = cursor.rowcount if cursor.rowcount >= 0 else 0
|
|
89
103
|
|