tina4-python 3.13.67__tar.gz → 3.13.69__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.13.67 → tina4_python-3.13.69}/PKG-INFO +1 -1
- {tina4_python-3.13.67 → tina4_python-3.13.69}/pyproject.toml +1 -1
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/__init__.py +39 -1
- tina4_python-3.13.69/tina4_python/api/__init__.py +537 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/cli/__init__.py +12 -2
- tina4_python-3.13.67/tina4_python/api/__init__.py +0 -273
- {tina4_python-3.13.67 → tina4_python-3.13.69}/.gitignore +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/README.md +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/env.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.67 → tina4_python-3.13.69}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -8,7 +8,45 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
|
|
|
8
8
|
|
|
9
9
|
One import, everything works.
|
|
10
10
|
"""
|
|
11
|
-
|
|
11
|
+
def _resolve_version() -> str:
|
|
12
|
+
"""Resolve the package version from a single source of truth.
|
|
13
|
+
|
|
14
|
+
Derive it rather than hardcode it, so a release only has to bump
|
|
15
|
+
``pyproject.toml`` and can never leave this constant stale (which used to
|
|
16
|
+
make the dev toolbar and ``docs._detect_version`` under-report). Mirrors how
|
|
17
|
+
Node reads package.json and PHP resolves ``App::$VERSION`` at runtime.
|
|
18
|
+
|
|
19
|
+
Order:
|
|
20
|
+
1. The repo's own ``pyproject.toml`` (a source checkout is authoritative
|
|
21
|
+
and current; installed metadata can lag an un-synced checkout). Guarded
|
|
22
|
+
by the project name so a copy vendored under another app's pyproject
|
|
23
|
+
can't hijack the version.
|
|
24
|
+
2. Installed distribution metadata (the shipped wheel has no pyproject).
|
|
25
|
+
3. A floor literal (last resort only).
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
import pathlib
|
|
29
|
+
import tomllib
|
|
30
|
+
pyproject = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
31
|
+
if pyproject.exists():
|
|
32
|
+
with pyproject.open("rb") as fh:
|
|
33
|
+
project = tomllib.load(fh).get("project", {})
|
|
34
|
+
if project.get("name") == "tina4-python" and project.get("version"):
|
|
35
|
+
return project["version"]
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
try:
|
|
39
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
40
|
+
try:
|
|
41
|
+
return version("tina4-python")
|
|
42
|
+
except PackageNotFoundError:
|
|
43
|
+
pass
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
return "3.13.56"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__version__ = _resolve_version()
|
|
12
50
|
|
|
13
51
|
# ── Route decorators ──
|
|
14
52
|
from tina4_python.core.router import ( # noqa: E402, F401
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
# Tina4 API Client — HTTP client using Python stdlib only.
|
|
2
|
+
"""
|
|
3
|
+
Make HTTP requests without requests/httpx/aiohttp.
|
|
4
|
+
|
|
5
|
+
from tina4_python.api import Api
|
|
6
|
+
|
|
7
|
+
api = Api("https://api.example.com")
|
|
8
|
+
result = api.get("/users")
|
|
9
|
+
result = api.post("/users", {"name": "Alice"})
|
|
10
|
+
|
|
11
|
+
Multipart upload (from disk or in-memory bytes), streaming download,
|
|
12
|
+
an injectable transport seam (for USERS to unit-test their own code),
|
|
13
|
+
and an opt-in per-client cookie jar are all built on the same zero-dependency
|
|
14
|
+
urllib core.
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import json
|
|
18
|
+
import ssl
|
|
19
|
+
import time
|
|
20
|
+
import base64
|
|
21
|
+
import secrets
|
|
22
|
+
import mimetypes
|
|
23
|
+
from urllib.parse import urlencode, urlparse
|
|
24
|
+
from urllib.request import Request, HTTPRedirectHandler, HTTPSHandler, build_opener
|
|
25
|
+
from urllib.error import HTTPError, URLError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Statuses that warrant an automatic retry when ``max_retries`` > 0: rate-limit
|
|
29
|
+
# (429) plus the transient server-side 5xx family. 4xx client errors (401,
|
|
30
|
+
# 404, …) are NOT retried — a repeat won't succeed.
|
|
31
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
32
|
+
|
|
33
|
+
# Streaming download reads/writes this many bytes per chunk so a multi-megabyte
|
|
34
|
+
# body never lands in memory in one piece.
|
|
35
|
+
_DOWNLOAD_CHUNK_SIZE = 64 * 1024
|
|
36
|
+
|
|
37
|
+
# Headers dropped when a redirect crosses to a different origin — a bearer token
|
|
38
|
+
# or a session cookie must never be handed to a host you didn't authenticate to.
|
|
39
|
+
_STRIP_ON_CROSS_ORIGIN = frozenset({"authorization", "cookie"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _same_origin(url_a: str, url_b: str) -> bool:
|
|
43
|
+
"""True when two URLs share scheme + host + (effective) port."""
|
|
44
|
+
a, b = urlparse(url_a), urlparse(url_b)
|
|
45
|
+
default = {"http": 80, "https": 443}
|
|
46
|
+
pa = a.port if a.port is not None else default.get(a.scheme)
|
|
47
|
+
pb = b.port if b.port is not None else default.get(b.scheme)
|
|
48
|
+
return (a.scheme, a.hostname, pa) == (b.scheme, b.hostname, pb)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _AuthStripRedirectHandler(HTTPRedirectHandler):
|
|
52
|
+
"""Follow redirects, but drop the Authorization (and Cookie) header on a
|
|
53
|
+
cross-origin hop.
|
|
54
|
+
|
|
55
|
+
Plain urllib forwards the Authorization header to ANY redirect target,
|
|
56
|
+
including a different host — so an ``api.get("/login")`` that 302s to
|
|
57
|
+
``https://attacker.example/`` would hand the bearer token to the attacker.
|
|
58
|
+
Stripping it when the target origin (scheme/host/port) differs matches
|
|
59
|
+
requests/httpx and closes that leak, while same-origin redirects keep auth.
|
|
60
|
+
The cookie jar's ``Cookie`` header is stripped on the same rule for the
|
|
61
|
+
identical reason.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
65
|
+
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
66
|
+
if new_req is not None and not _same_origin(req.full_url, newurl):
|
|
67
|
+
new_req.headers = {
|
|
68
|
+
k: v for k, v in new_req.headers.items()
|
|
69
|
+
if k.lower() not in _STRIP_ON_CROSS_ORIGIN
|
|
70
|
+
}
|
|
71
|
+
new_req.unredirected_hdrs = {
|
|
72
|
+
k: v for k, v in getattr(new_req, "unredirected_hdrs", {}).items()
|
|
73
|
+
if k.lower() not in _STRIP_ON_CROSS_ORIGIN
|
|
74
|
+
}
|
|
75
|
+
return new_req
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _open(req, timeout, opener):
|
|
79
|
+
"""The single network-call indirection point (keeps the call site easy to
|
|
80
|
+
patch in tests). ``req`` stays the first positional arg on purpose."""
|
|
81
|
+
return opener.open(req, timeout=timeout)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Api:
|
|
85
|
+
"""HTTP client using urllib — zero external dependencies."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, base_url: str = "", auth_header: str = "",
|
|
88
|
+
ignore_ssl: bool = False, timeout: int = 30,
|
|
89
|
+
bearer_token: str | None = None,
|
|
90
|
+
username: str | None = None,
|
|
91
|
+
password: str | None = None,
|
|
92
|
+
headers: dict[str, str] | None = None,
|
|
93
|
+
verify_ssl: bool | None = None,
|
|
94
|
+
max_retries: int = 0,
|
|
95
|
+
retry_backoff: float = 0.5,
|
|
96
|
+
transport=None,
|
|
97
|
+
cookies: bool = False):
|
|
98
|
+
"""HTTP client.
|
|
99
|
+
|
|
100
|
+
Constructor accepts ergonomic kwargs the documentation has long
|
|
101
|
+
described — every modern Python HTTP library (requests, httpx)
|
|
102
|
+
accepts these directly rather than requiring post-construction
|
|
103
|
+
setter calls.
|
|
104
|
+
|
|
105
|
+
api = Api("https://api.example.com", bearer_token="sk-...")
|
|
106
|
+
api = Api("https://api.example.com", username="u", password="p")
|
|
107
|
+
api = Api("https://api.example.com", headers={"X-Tenant": "acme"})
|
|
108
|
+
api = Api("https://self-signed.local", verify_ssl=False)
|
|
109
|
+
|
|
110
|
+
The setter-based API (``set_bearer_token``, ``set_basic_auth``,
|
|
111
|
+
``add_headers``) continues to work; pick whichever reads better.
|
|
112
|
+
|
|
113
|
+
``verify_ssl`` is the docs-friendly inverse of ``ignore_ssl`` —
|
|
114
|
+
``verify_ssl=False`` is equivalent to ``ignore_ssl=True``. If
|
|
115
|
+
both are supplied, ``ignore_ssl`` wins (legacy precedence).
|
|
116
|
+
|
|
117
|
+
``max_retries`` (default 0 = off) enables automatic retry with
|
|
118
|
+
exponential backoff (``retry_backoff`` seconds base, doubling each
|
|
119
|
+
attempt) on a transport error or a retryable status (429/5xx). A
|
|
120
|
+
retried non-idempotent request (POST/…) may be re-sent — retries are
|
|
121
|
+
opt-in for that reason.
|
|
122
|
+
|
|
123
|
+
``transport`` (default None = the real urllib network path) is an
|
|
124
|
+
injectable seam so that USERS can unit-test their own code without a
|
|
125
|
+
live server. When supplied it must be a callable with the signature
|
|
126
|
+
``transport(method, url, headers, body, timeout)`` returning the same
|
|
127
|
+
result dict every verb returns: ``{"http_code", "body", "headers",
|
|
128
|
+
"error"}``. It fully REPLACES the network call.
|
|
129
|
+
|
|
130
|
+
NOTE: Tina4's own test suite must NEVER inject a fake/canned transport
|
|
131
|
+
— the no-mock rule stands, so framework tests always exercise the real
|
|
132
|
+
network path against a real local server. The seam exists purely so
|
|
133
|
+
*application* developers can test code that calls an ``Api`` instance.
|
|
134
|
+
|
|
135
|
+
``cookies`` (default False = off, zero behaviour change) turns on a
|
|
136
|
+
per-client, in-memory cookie jar: ``Set-Cookie`` headers on responses
|
|
137
|
+
are parsed and the accumulated ``Cookie`` header is sent on subsequent
|
|
138
|
+
requests. The jar is not persisted and is scoped to this instance.
|
|
139
|
+
"""
|
|
140
|
+
self.base_url = base_url.rstrip("/")
|
|
141
|
+
self.auth_header = auth_header
|
|
142
|
+
self.timeout = timeout
|
|
143
|
+
self.max_retries = max(0, int(max_retries))
|
|
144
|
+
self.retry_backoff = retry_backoff
|
|
145
|
+
self._headers: dict[str, str] = {}
|
|
146
|
+
self._ssl_context = None
|
|
147
|
+
self._opener_cache = None
|
|
148
|
+
self._transport = transport
|
|
149
|
+
self._cookies_enabled = bool(cookies)
|
|
150
|
+
self._cookies: dict[str, str] = {}
|
|
151
|
+
|
|
152
|
+
# ── kwarg sugar ────────────────────────────────────────────────
|
|
153
|
+
# Bearer token wins over basic auth if both are passed.
|
|
154
|
+
if bearer_token is not None:
|
|
155
|
+
self.set_bearer_token(bearer_token)
|
|
156
|
+
elif username is not None and password is not None:
|
|
157
|
+
self.set_basic_auth(username, password)
|
|
158
|
+
|
|
159
|
+
if headers:
|
|
160
|
+
self._headers.update(headers)
|
|
161
|
+
|
|
162
|
+
# ignore_ssl is the existing flag; verify_ssl=False is the same thing
|
|
163
|
+
# expressed positively. Honour ignore_ssl when both are set.
|
|
164
|
+
if ignore_ssl or (verify_ssl is False):
|
|
165
|
+
self._ssl_context = ssl.create_default_context()
|
|
166
|
+
self._ssl_context.check_hostname = False
|
|
167
|
+
self._ssl_context.verify_mode = ssl.CERT_NONE
|
|
168
|
+
|
|
169
|
+
def add_headers(self, headers: dict[str, str]):
|
|
170
|
+
"""Add custom headers to all requests."""
|
|
171
|
+
self._headers.update(headers)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def set_basic_auth(self, username: str, password: str):
|
|
175
|
+
"""Set Basic authentication."""
|
|
176
|
+
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
177
|
+
self.auth_header = f"Basic {creds}"
|
|
178
|
+
|
|
179
|
+
def set_bearer_token(self, token: str):
|
|
180
|
+
"""Set Bearer token authentication."""
|
|
181
|
+
self.auth_header = f"Bearer {token}"
|
|
182
|
+
|
|
183
|
+
def get(self, path: str = "", params: dict = None) -> dict:
|
|
184
|
+
"""HTTP GET request."""
|
|
185
|
+
url = self._url(path)
|
|
186
|
+
if params:
|
|
187
|
+
url += "?" + urlencode(params)
|
|
188
|
+
return self._request("GET", url)
|
|
189
|
+
|
|
190
|
+
def post(self, path: str = "", body=None, content_type: str = "application/json") -> dict:
|
|
191
|
+
"""HTTP POST request."""
|
|
192
|
+
return self._request("POST", self._url(path), body, content_type)
|
|
193
|
+
|
|
194
|
+
def put(self, path: str = "", body=None, content_type: str = "application/json") -> dict:
|
|
195
|
+
"""HTTP PUT request."""
|
|
196
|
+
return self._request("PUT", self._url(path), body, content_type)
|
|
197
|
+
|
|
198
|
+
def patch(self, path: str = "", body=None, content_type: str = "application/json") -> dict:
|
|
199
|
+
"""HTTP PATCH request."""
|
|
200
|
+
return self._request("PATCH", self._url(path), body, content_type)
|
|
201
|
+
|
|
202
|
+
def delete(self, path: str = "", body=None) -> dict:
|
|
203
|
+
"""HTTP DELETE request."""
|
|
204
|
+
return self._request("DELETE", self._url(path), body)
|
|
205
|
+
|
|
206
|
+
def send(self, method: str, path: str = "", body=None,
|
|
207
|
+
content_type: str = "application/json") -> dict:
|
|
208
|
+
"""Generic request method — pick HTTP verb at call time.
|
|
209
|
+
|
|
210
|
+
Renamed from ``send_request`` in 3.13.0 for parity with the
|
|
211
|
+
documentation and conciseness (``api.send("PATCH", ...)`` reads
|
|
212
|
+
cleaner than ``api.send_request("PATCH", ...)``).
|
|
213
|
+
"""
|
|
214
|
+
return self._request(method.upper(), self._url(path), body, content_type)
|
|
215
|
+
|
|
216
|
+
def upload(self, path: str = "", file_path: str | None = None,
|
|
217
|
+
field_name: str = "file", extra_fields: dict | None = None,
|
|
218
|
+
headers: dict[str, str] | None = None,
|
|
219
|
+
file_bytes: bytes | None = None,
|
|
220
|
+
filename: str | None = None) -> dict:
|
|
221
|
+
"""POST a ``multipart/form-data`` body — a file plus optional text fields.
|
|
222
|
+
|
|
223
|
+
Two ways to supply the file, so a caller never needs a temp file:
|
|
224
|
+
|
|
225
|
+
- ``file_path`` — a file on disk. ``filename`` defaults to its basename.
|
|
226
|
+
- ``file_bytes`` + ``filename`` — an in-memory payload (bytes/str).
|
|
227
|
+
|
|
228
|
+
``field_name`` is the form field the file is sent under (default
|
|
229
|
+
``"file"``). ``extra_fields`` (dict) become additional text parts.
|
|
230
|
+
``headers`` (dict) are extra per-call headers merged onto the request.
|
|
231
|
+
The part's ``Content-Type`` is guessed from the filename via
|
|
232
|
+
``mimetypes`` (falling back to ``application/octet-stream``).
|
|
233
|
+
|
|
234
|
+
Returns the standard result dict ``{"http_code", "body", "headers",
|
|
235
|
+
"error"}``. A missing file or no source given returns a clean error
|
|
236
|
+
dict (``http_code`` None, ``error`` set) — it does NOT raise.
|
|
237
|
+
|
|
238
|
+
api.upload("/avatars", file_path="/tmp/me.png")
|
|
239
|
+
api.upload("/avatars", file_bytes=raw, filename="me.png",
|
|
240
|
+
extra_fields={"user_id": "42"})
|
|
241
|
+
"""
|
|
242
|
+
if file_bytes is not None:
|
|
243
|
+
content = (file_bytes if isinstance(file_bytes, (bytes, bytearray))
|
|
244
|
+
else str(file_bytes).encode("utf-8"))
|
|
245
|
+
upload_name = filename or "upload.bin"
|
|
246
|
+
elif file_path:
|
|
247
|
+
if not os.path.isfile(file_path):
|
|
248
|
+
return {"http_code": None, "body": None, "headers": {},
|
|
249
|
+
"error": f"file not found: {file_path}"}
|
|
250
|
+
try:
|
|
251
|
+
with open(file_path, "rb") as file_handle:
|
|
252
|
+
content = file_handle.read()
|
|
253
|
+
except OSError as read_error:
|
|
254
|
+
return {"http_code": None, "body": None, "headers": {},
|
|
255
|
+
"error": str(read_error)}
|
|
256
|
+
upload_name = filename or os.path.basename(file_path)
|
|
257
|
+
else:
|
|
258
|
+
return {"http_code": None, "body": None, "headers": {},
|
|
259
|
+
"error": "upload requires file_path or file_bytes"}
|
|
260
|
+
|
|
261
|
+
part_content_type = mimetypes.guess_type(upload_name)[0] or "application/octet-stream"
|
|
262
|
+
boundary = "----Tina4Boundary" + secrets.token_hex(16)
|
|
263
|
+
body = self._build_multipart_body(
|
|
264
|
+
boundary, field_name, upload_name, bytes(content),
|
|
265
|
+
part_content_type, extra_fields)
|
|
266
|
+
content_type = f"multipart/form-data; boundary={boundary}"
|
|
267
|
+
return self._request("POST", self._url(path), body, content_type,
|
|
268
|
+
extra_headers=headers)
|
|
269
|
+
|
|
270
|
+
def download(self, path: str = "", dest_path: str | None = None,
|
|
271
|
+
params: dict | None = None) -> dict:
|
|
272
|
+
"""Stream a GET response body to ``dest_path`` in chunks.
|
|
273
|
+
|
|
274
|
+
The body is written to disk ``_DOWNLOAD_CHUNK_SIZE`` bytes at a time
|
|
275
|
+
instead of being buffered whole in memory — safe for large payloads.
|
|
276
|
+
Uses the same opener as every other verb (redirect following, the
|
|
277
|
+
cross-origin auth strip, and the SSL context all apply).
|
|
278
|
+
|
|
279
|
+
Returns ``{"http_code", "headers", "error", "path"}`` — there is no
|
|
280
|
+
``"body"`` key (it went to disk). ``path`` is ``dest_path`` on success
|
|
281
|
+
and ``None`` on any error (missing dest, HTTP error status, or a
|
|
282
|
+
transport failure), and the destination file is not written on error.
|
|
283
|
+
"""
|
|
284
|
+
if not dest_path:
|
|
285
|
+
return {"http_code": None, "headers": {},
|
|
286
|
+
"error": "download requires dest_path", "path": None}
|
|
287
|
+
|
|
288
|
+
url = self._url(path)
|
|
289
|
+
if params:
|
|
290
|
+
url += "?" + urlencode(params)
|
|
291
|
+
req = self._build_request("GET", url, None, "application/json")
|
|
292
|
+
|
|
293
|
+
# An injected transport can't stream (it returns a buffered result), so
|
|
294
|
+
# write its body out; only the real urllib path streams chunk-by-chunk.
|
|
295
|
+
if self._transport is not None:
|
|
296
|
+
result = self._call_transport(req)
|
|
297
|
+
self._store_cookies(result["headers"])
|
|
298
|
+
code = result["http_code"]
|
|
299
|
+
if result["error"] is None and code is not None and 200 <= code < 300:
|
|
300
|
+
data = result["body"]
|
|
301
|
+
if isinstance(data, str):
|
|
302
|
+
data = data.encode("utf-8")
|
|
303
|
+
elif not isinstance(data, (bytes, bytearray)):
|
|
304
|
+
data = json.dumps(data, default=str).encode("utf-8")
|
|
305
|
+
try:
|
|
306
|
+
with open(dest_path, "wb") as out_file:
|
|
307
|
+
out_file.write(data)
|
|
308
|
+
except OSError as write_error:
|
|
309
|
+
return {"http_code": code, "headers": result["headers"],
|
|
310
|
+
"error": str(write_error), "path": None}
|
|
311
|
+
return {"http_code": code, "headers": result["headers"],
|
|
312
|
+
"error": None, "path": dest_path}
|
|
313
|
+
return {"http_code": code, "headers": result["headers"],
|
|
314
|
+
"error": result["error"] or f"download failed (HTTP {code})",
|
|
315
|
+
"path": None}
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
resp = _open(req, self.timeout, self._opener())
|
|
319
|
+
self._store_cookies(resp.headers)
|
|
320
|
+
with open(dest_path, "wb") as out_file:
|
|
321
|
+
while True:
|
|
322
|
+
chunk = resp.read(_DOWNLOAD_CHUNK_SIZE)
|
|
323
|
+
if not chunk:
|
|
324
|
+
break
|
|
325
|
+
out_file.write(chunk)
|
|
326
|
+
return {"http_code": resp.status, "headers": dict(resp.headers),
|
|
327
|
+
"error": None, "path": dest_path}
|
|
328
|
+
except HTTPError as http_error:
|
|
329
|
+
return {"http_code": http_error.code,
|
|
330
|
+
"headers": dict(http_error.headers) if http_error.headers else {},
|
|
331
|
+
"error": str(http_error), "path": None}
|
|
332
|
+
except URLError as url_error:
|
|
333
|
+
return {"http_code": None, "headers": {},
|
|
334
|
+
"error": str(url_error.reason), "path": None}
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
return {"http_code": None, "headers": {}, "error": str(exc), "path": None}
|
|
337
|
+
|
|
338
|
+
def _url(self, path: str) -> str:
|
|
339
|
+
if path.startswith("http"):
|
|
340
|
+
return path
|
|
341
|
+
return f"{self.base_url}/{path.lstrip('/')}" if path else self.base_url
|
|
342
|
+
|
|
343
|
+
def _opener(self):
|
|
344
|
+
"""Build (once) an opener that follows redirects but strips the
|
|
345
|
+
Authorization/Cookie header on a cross-origin hop, honouring the SSL
|
|
346
|
+
context."""
|
|
347
|
+
if self._opener_cache is None:
|
|
348
|
+
handlers = [_AuthStripRedirectHandler()]
|
|
349
|
+
if self._ssl_context is not None:
|
|
350
|
+
handlers.append(HTTPSHandler(context=self._ssl_context))
|
|
351
|
+
self._opener_cache = build_opener(*handlers)
|
|
352
|
+
return self._opener_cache
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _build_multipart_body(boundary: str, field_name: str, filename: str,
|
|
356
|
+
file_content: bytes, content_type: str,
|
|
357
|
+
extra_fields: dict | None) -> bytes:
|
|
358
|
+
"""Assemble a ``multipart/form-data`` body as raw bytes.
|
|
359
|
+
|
|
360
|
+
Text fields come first, then the file part, then the closing delimiter
|
|
361
|
+
— matching the canonical Ruby ``build_multipart_body`` shape so every
|
|
362
|
+
framework produces a byte-identical layout.
|
|
363
|
+
"""
|
|
364
|
+
crlf = b"\r\n"
|
|
365
|
+
delimiter = ("--" + boundary).encode("utf-8")
|
|
366
|
+
chunks: list[bytes] = []
|
|
367
|
+
for key, value in (extra_fields or {}).items():
|
|
368
|
+
chunks.append(delimiter + crlf)
|
|
369
|
+
chunks.append(f'Content-Disposition: form-data; name="{key}"'.encode("utf-8")
|
|
370
|
+
+ crlf + crlf)
|
|
371
|
+
chunks.append(str(value).encode("utf-8") + crlf)
|
|
372
|
+
chunks.append(delimiter + crlf)
|
|
373
|
+
disposition = f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"'
|
|
374
|
+
chunks.append(disposition.encode("utf-8") + crlf)
|
|
375
|
+
chunks.append(f"Content-Type: {content_type}".encode("utf-8") + crlf + crlf)
|
|
376
|
+
chunks.append(file_content + crlf)
|
|
377
|
+
chunks.append(delimiter + b"--" + crlf)
|
|
378
|
+
return b"".join(chunks)
|
|
379
|
+
|
|
380
|
+
def _build_request(self, method: str, url: str, body, content_type: str,
|
|
381
|
+
extra_headers: dict | None = None) -> Request:
|
|
382
|
+
headers = dict(self._headers)
|
|
383
|
+
if self.auth_header:
|
|
384
|
+
headers["Authorization"] = self.auth_header
|
|
385
|
+
|
|
386
|
+
# Cookie jar: attach the accumulated Cookie header when enabled.
|
|
387
|
+
if self._cookies_enabled:
|
|
388
|
+
cookie_header = self._cookie_header()
|
|
389
|
+
if cookie_header:
|
|
390
|
+
headers["Cookie"] = cookie_header
|
|
391
|
+
|
|
392
|
+
data = None
|
|
393
|
+
if body is not None:
|
|
394
|
+
if content_type == "application/json" and isinstance(body, (dict, list)):
|
|
395
|
+
data = json.dumps(body, default=str).encode("utf-8")
|
|
396
|
+
headers["Content-Type"] = "application/json"
|
|
397
|
+
elif isinstance(body, str):
|
|
398
|
+
data = body.encode("utf-8")
|
|
399
|
+
headers["Content-Type"] = content_type
|
|
400
|
+
elif isinstance(body, bytes):
|
|
401
|
+
data = body
|
|
402
|
+
headers["Content-Type"] = content_type
|
|
403
|
+
|
|
404
|
+
if extra_headers:
|
|
405
|
+
headers.update(extra_headers)
|
|
406
|
+
|
|
407
|
+
return Request(url, data=data, headers=headers, method=method)
|
|
408
|
+
|
|
409
|
+
def _request(self, method: str, url: str, body=None,
|
|
410
|
+
content_type: str = "application/json",
|
|
411
|
+
extra_headers: dict | None = None) -> dict:
|
|
412
|
+
"""Execute the request with opt-in retry/backoff. Returns a result dict.
|
|
413
|
+
|
|
414
|
+
With ``max_retries`` > 0, a transport failure (``http_code`` None) or a
|
|
415
|
+
retryable status (429/5xx) is retried up to ``max_retries`` times with
|
|
416
|
+
exponential backoff; any other outcome (2xx, 4xx, 3xx) returns at once.
|
|
417
|
+
"""
|
|
418
|
+
req = self._build_request(method, url, body, content_type, extra_headers)
|
|
419
|
+
attempts = self.max_retries + 1
|
|
420
|
+
result = None
|
|
421
|
+
for attempt in range(attempts):
|
|
422
|
+
result = self._attempt(req)
|
|
423
|
+
code = result.get("http_code")
|
|
424
|
+
retryable = code is None or code in _RETRY_STATUSES
|
|
425
|
+
if not retryable or attempt == attempts - 1:
|
|
426
|
+
return result
|
|
427
|
+
time.sleep(self.retry_backoff * (2 ** attempt))
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
def _call_transport(self, req: Request) -> dict:
|
|
431
|
+
"""Invoke a user-injected transport and normalize its result dict.
|
|
432
|
+
|
|
433
|
+
The transport fully replaces the network call; it is called with the
|
|
434
|
+
request as ``(method, url, headers, body, timeout)``.
|
|
435
|
+
"""
|
|
436
|
+
try:
|
|
437
|
+
result = self._transport(
|
|
438
|
+
req.get_method(),
|
|
439
|
+
req.full_url,
|
|
440
|
+
dict(req.header_items()),
|
|
441
|
+
req.data,
|
|
442
|
+
self.timeout,
|
|
443
|
+
)
|
|
444
|
+
except Exception as exc:
|
|
445
|
+
return {"http_code": None, "body": None, "headers": {}, "error": str(exc)}
|
|
446
|
+
return {
|
|
447
|
+
"http_code": result.get("http_code"),
|
|
448
|
+
"body": result.get("body"),
|
|
449
|
+
"headers": result.get("headers") or {},
|
|
450
|
+
"error": result.get("error"),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
def _attempt(self, req: Request) -> dict:
|
|
454
|
+
"""A single HTTP attempt. Returns the standardized result dict."""
|
|
455
|
+
if self._transport is not None:
|
|
456
|
+
result = self._call_transport(req)
|
|
457
|
+
self._store_cookies(result["headers"])
|
|
458
|
+
return result
|
|
459
|
+
try:
|
|
460
|
+
resp = _open(req, self.timeout, self._opener())
|
|
461
|
+
self._store_cookies(resp.headers)
|
|
462
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
463
|
+
resp_headers = dict(resp.headers)
|
|
464
|
+
try:
|
|
465
|
+
parsed = json.loads(raw)
|
|
466
|
+
except (json.JSONDecodeError, ValueError):
|
|
467
|
+
parsed = raw
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
"http_code": resp.status,
|
|
471
|
+
"body": parsed,
|
|
472
|
+
"headers": resp_headers,
|
|
473
|
+
"error": None,
|
|
474
|
+
}
|
|
475
|
+
except HTTPError as e:
|
|
476
|
+
self._store_cookies(e.headers)
|
|
477
|
+
raw = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
|
478
|
+
try:
|
|
479
|
+
parsed = json.loads(raw)
|
|
480
|
+
except (json.JSONDecodeError, ValueError):
|
|
481
|
+
parsed = raw
|
|
482
|
+
return {
|
|
483
|
+
"http_code": e.code,
|
|
484
|
+
"body": parsed,
|
|
485
|
+
"headers": dict(e.headers) if e.headers else {},
|
|
486
|
+
"error": str(e),
|
|
487
|
+
}
|
|
488
|
+
except URLError as e:
|
|
489
|
+
return {
|
|
490
|
+
"http_code": None,
|
|
491
|
+
"body": None,
|
|
492
|
+
"headers": {},
|
|
493
|
+
"error": str(e.reason),
|
|
494
|
+
}
|
|
495
|
+
except Exception as e:
|
|
496
|
+
return {
|
|
497
|
+
"http_code": None,
|
|
498
|
+
"body": None,
|
|
499
|
+
"headers": {},
|
|
500
|
+
"error": str(e),
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
# ── cookie jar (opt-in, in-memory, per-client) ─────────────────────────
|
|
504
|
+
def _cookie_header(self) -> str | None:
|
|
505
|
+
"""The accumulated ``Cookie`` request header, or None when empty."""
|
|
506
|
+
if not self._cookies:
|
|
507
|
+
return None
|
|
508
|
+
return "; ".join(f"{name}={value}" for name, value in self._cookies.items())
|
|
509
|
+
|
|
510
|
+
def _store_cookies(self, headers) -> None:
|
|
511
|
+
"""Parse ``Set-Cookie`` response headers into the jar (when enabled).
|
|
512
|
+
|
|
513
|
+
Only the leading ``name=value`` pair of each ``Set-Cookie`` header is
|
|
514
|
+
kept (attributes like Path/HttpOnly/Expires are ignored); a later value
|
|
515
|
+
for the same name overwrites an earlier one. ``headers`` may be an
|
|
516
|
+
``http.client.HTTPMessage`` (real path — supports multiple
|
|
517
|
+
``Set-Cookie`` via ``get_all``) or a plain dict (transport seam).
|
|
518
|
+
"""
|
|
519
|
+
if not self._cookies_enabled or headers is None:
|
|
520
|
+
return
|
|
521
|
+
raw_values: list[str] = []
|
|
522
|
+
if hasattr(headers, "get_all"):
|
|
523
|
+
raw_values = headers.get_all("Set-Cookie") or []
|
|
524
|
+
elif isinstance(headers, dict):
|
|
525
|
+
for key, value in headers.items():
|
|
526
|
+
if key.lower() == "set-cookie" and value:
|
|
527
|
+
raw_values.append(value)
|
|
528
|
+
for raw in raw_values:
|
|
529
|
+
first_pair = raw.split(";", 1)[0].strip()
|
|
530
|
+
if "=" in first_pair:
|
|
531
|
+
name, _, value = first_pair.partition("=")
|
|
532
|
+
name = name.strip()
|
|
533
|
+
if name:
|
|
534
|
+
self._cookies[name] = value.strip()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
__all__ = ["Api"]
|
|
@@ -311,16 +311,26 @@ def _console(args=None):
|
|
|
311
311
|
|
|
312
312
|
# Import everything the user needs
|
|
313
313
|
from tina4_python import get, post, put, patch, delete, Router, Database, ORM, Auth, Queue, Frond
|
|
314
|
+
from tina4_python.orm import bind_database
|
|
314
315
|
from tina4_python.debug import Log
|
|
315
316
|
from tina4_python.api import Api
|
|
316
317
|
from tina4_python.core.events import on, emit
|
|
317
318
|
|
|
318
|
-
# Try to connect database from TINA4_DATABASE_URL
|
|
319
|
+
# Try to connect database from TINA4_DATABASE_URL. Honour the SEPARATE
|
|
320
|
+
# credential env vars (TINA4_DATABASE_USERNAME/PASSWORD) — the documented
|
|
321
|
+
# Tina4 convention keeps credentials out of the URL, so building the handle
|
|
322
|
+
# from the URL alone left `db` unauthenticated and it failed on every
|
|
323
|
+
# credentialed engine (PostgreSQL/MySQL/Firebird/MSSQL). Bind it globally
|
|
324
|
+
# too, so the `db` handle and the ORM models share ONE connected instance
|
|
325
|
+
# (mirrors ORM._get_db()'s own lazy auto-bind).
|
|
319
326
|
db = None
|
|
320
327
|
db_url = os.environ.get("TINA4_DATABASE_URL")
|
|
321
328
|
if db_url:
|
|
322
329
|
try:
|
|
323
|
-
|
|
330
|
+
username = os.environ.get("TINA4_DATABASE_USERNAME", "")
|
|
331
|
+
password = os.environ.get("TINA4_DATABASE_PASSWORD", "")
|
|
332
|
+
db = Database(db_url, username, password)
|
|
333
|
+
bind_database(db)
|
|
324
334
|
print(f" Database: {db_url}")
|
|
325
335
|
except Exception as e:
|
|
326
336
|
print(f" Database: failed ({e})")
|