tina4-python 3.13.136__tar.gz → 3.13.137__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.136 → tina4_python-3.13.137}/PKG-INFO +1 -1
- {tina4_python-3.13.136 → tina4_python-3.13.137}/pyproject.toml +7 -4
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/ai/client.py +14 -6
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/server.py +5 -1
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/migration/runner.py +23 -9
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/push/__init__.py +59 -35
- tina4_python-3.13.137/tina4_python/push/_base.py +38 -0
- tina4_python-3.13.137/tina4_python/push/_cryptography.py +66 -0
- tina4_python-3.13.137/tina4_python/push/_libcrypto.py +261 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/.gitignore +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/README.md +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/.tina4-metrics.json +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/_import_helper.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/port_takeover.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/sql_translator.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/env.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapter.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapters/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapters/arango.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapters/bolt.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/adapters/ultipa.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graph/graph_url.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/collection.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/orm/point.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/sso.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.136 → tina4_python-3.13.137}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tina4-python"
|
|
3
|
-
version = "3.13.
|
|
3
|
+
version = "3.13.137"
|
|
4
4
|
description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
|
|
@@ -41,9 +41,12 @@ s3 = ["boto3>=1.34"]
|
|
|
41
41
|
kafka = ["confluent-kafka>=2.0"]
|
|
42
42
|
rabbitmq = ["pika>=1.3"]
|
|
43
43
|
|
|
44
|
-
# Web Push is zero-dependency
|
|
45
|
-
#
|
|
46
|
-
#
|
|
44
|
+
# Web Push is zero-dependency on the server: the crypto runs through the OS
|
|
45
|
+
# OpenSSL (libcrypto) by ctypes, so a Linux host needs nothing installed. This
|
|
46
|
+
# extra is the DEV FALLBACK - on a stock Mac or a Windows workstation ctypes
|
|
47
|
+
# cannot reach a safe OpenSSL, so the cryptography package runs instead. Install
|
|
48
|
+
# it there (or pin it with TINA4_PUSH_BACKEND=cryptography); the wire output is
|
|
49
|
+
# identical either way. See tina4_python/push/_libcrypto.py.
|
|
47
50
|
push = ["cryptography>=42.0"]
|
|
48
51
|
|
|
49
52
|
# All database drivers
|
|
@@ -98,7 +98,7 @@ class Ai:
|
|
|
98
98
|
Explicit call options beat environment values, which beat defaults.
|
|
99
99
|
"""
|
|
100
100
|
|
|
101
|
-
_PROVIDERS = {"local", "openai", "anthropic"}
|
|
101
|
+
_PROVIDERS = {"local", "openai", "anthropic", "gemini"}
|
|
102
102
|
|
|
103
103
|
@staticmethod
|
|
104
104
|
def chat(
|
|
@@ -369,15 +369,19 @@ class Ai:
|
|
|
369
369
|
def _config(capability: str, *, model: str | None, timeout: float | None, provider: str | None) -> _Config:
|
|
370
370
|
selected = (provider or os.environ.get("TINA4_AI_PROVIDER") or "local").strip().lower()
|
|
371
371
|
if selected not in Ai._PROVIDERS:
|
|
372
|
-
raise AiConfigError("TINA4_AI_PROVIDER must be local, openai, or
|
|
372
|
+
raise AiConfigError("TINA4_AI_PROVIDER must be local, openai, anthropic, or gemini")
|
|
373
373
|
key = os.environ.get("TINA4_AI_KEY") or None
|
|
374
|
-
if selected in {"openai", "anthropic"} and not key:
|
|
374
|
+
if selected in {"openai", "anthropic", "gemini"} and not key:
|
|
375
375
|
raise AiConfigError(f"TINA4_AI_KEY is required for the {selected} provider")
|
|
376
376
|
|
|
377
377
|
defaults = {
|
|
378
378
|
"local": ("http://localhost:11437", "llama3.2"),
|
|
379
379
|
"openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
|
|
380
380
|
"anthropic": ("https://api.anthropic.com/v1", "claude-3-5-haiku-latest"),
|
|
381
|
+
# Gemini speaks the OpenAI wire format at its OpenAI-compatible base, so it
|
|
382
|
+
# rides the openai body/parse/stream path - only the base URL, the endpoint
|
|
383
|
+
# suffix append, and the Bearer key differ.
|
|
384
|
+
"gemini": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"),
|
|
381
385
|
}
|
|
382
386
|
base, default_model = defaults[selected]
|
|
383
387
|
if capability == "embed" and os.environ.get("TINA4_EMBED_URL"):
|
|
@@ -408,8 +412,12 @@ class Ai:
|
|
|
408
412
|
suffix = "/embeddings"
|
|
409
413
|
else:
|
|
410
414
|
suffix = "/chat/completions"
|
|
411
|
-
|
|
412
|
-
|
|
415
|
+
# "/v1beta/openai" is Gemini's OpenAI-compatible base; append the suffix onto
|
|
416
|
+
# it exactly as onto a bare host or "/v1", so the default gemini base resolves
|
|
417
|
+
# to .../v1beta/openai/chat/completions (or /embeddings). A full endpoint URL
|
|
418
|
+
# the caller supplies verbatim still passes through untouched.
|
|
419
|
+
if path in {"", "/v1", "/api", "/v1beta/openai"}:
|
|
420
|
+
prefix = path or "/v1"
|
|
413
421
|
path = prefix + suffix
|
|
414
422
|
parsed = parsed._replace(path=path)
|
|
415
423
|
return parsed.geturl()
|
|
@@ -418,7 +426,7 @@ class Ai:
|
|
|
418
426
|
@staticmethod
|
|
419
427
|
def _headers(config: _Config) -> dict[str, str]:
|
|
420
428
|
headers = {"content-type": "application/json", "accept": "application/json"}
|
|
421
|
-
if config.provider
|
|
429
|
+
if config.provider in {"openai", "gemini"} and config.key:
|
|
422
430
|
headers["authorization"] = f"Bearer {config.key}"
|
|
423
431
|
elif config.provider == "anthropic" and config.key:
|
|
424
432
|
headers["x-api-key"] = config.key
|
|
@@ -2588,7 +2588,11 @@ def _stage_dev_toolbar_inject(ctx: DispatchContext) -> None:
|
|
|
2588
2588
|
Best-effort: a toolbar that fails to render must never break the response
|
|
2589
2589
|
it was decorating, so the whole thing is guarded.
|
|
2590
2590
|
"""
|
|
2591
|
-
if
|
|
2591
|
+
if (
|
|
2592
|
+
not ctx.is_dev
|
|
2593
|
+
or not ctx.response.content_type
|
|
2594
|
+
or ctx.response.status_code == 204
|
|
2595
|
+
):
|
|
2592
2596
|
return None
|
|
2593
2597
|
if "text/html" not in ctx.response.content_type:
|
|
2594
2598
|
return None
|
|
@@ -156,7 +156,13 @@ def _build_stem_map(migration_folder: str | None) -> list[str]:
|
|
|
156
156
|
def _resolve_migration_name(description: str, stems: list[str]) -> str:
|
|
157
157
|
"""Find the file stem that corresponds to a v2 description, or fall back.
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
Python v2 recorded `description` straight from os.listdir, so the value
|
|
160
|
+
normally carries the file extension (".sql"/".py") while a stem never does.
|
|
161
|
+
Match on the extension-stripped form as well, or a whole v2 history looks
|
|
162
|
+
unapplied and replays — see tests/test_issue_115_v2_upgrade.py.
|
|
163
|
+
|
|
164
|
+
Match strategy, in order (each tried against the description and against
|
|
165
|
+
its extension-stripped form):
|
|
160
166
|
1. Exact stem match.
|
|
161
167
|
2. A stem ending in "_" + description (e.g. "000001_create_users"
|
|
162
168
|
resolves a v2 description of "create_users").
|
|
@@ -165,14 +171,22 @@ def _resolve_migration_name(description: str, stems: list[str]) -> str:
|
|
|
165
171
|
getAppliedMigrations() but migrate() may re-run if a real file
|
|
166
172
|
exists with a different name.
|
|
167
173
|
"""
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for
|
|
174
|
-
if
|
|
175
|
-
return
|
|
174
|
+
candidates = [description]
|
|
175
|
+
bare = re.sub(r"\.(sql|py)$", "", description, flags=re.IGNORECASE)
|
|
176
|
+
if bare != description:
|
|
177
|
+
candidates.append(bare)
|
|
178
|
+
|
|
179
|
+
for candidate in candidates:
|
|
180
|
+
if candidate in stems:
|
|
181
|
+
return candidate
|
|
182
|
+
for candidate in candidates:
|
|
183
|
+
for stem in stems:
|
|
184
|
+
if stem.endswith("_" + candidate):
|
|
185
|
+
return stem
|
|
186
|
+
for candidate in candidates:
|
|
187
|
+
for stem in stems:
|
|
188
|
+
if candidate in stem:
|
|
189
|
+
return stem
|
|
176
190
|
return description
|
|
177
191
|
|
|
178
192
|
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"""Provider-neutral Web Push delivery (RFC 8291 / RFC 8292).
|
|
2
2
|
|
|
3
|
-
The framework
|
|
4
|
-
``
|
|
5
|
-
|
|
3
|
+
The framework stays zero-dependency on the server. The crypto runs through the OS
|
|
4
|
+
OpenSSL (``libcrypto``) by ctypes - the same "use the platform crypto" model the
|
|
5
|
+
PHP, Ruby and Node frameworks get from their stdlib - so a Linux host sends push
|
|
6
|
+
with nothing installed. Where ctypes cannot reach a safe OpenSSL (a stock Mac, a
|
|
7
|
+
Windows workstation), it falls back to the optional ``cryptography`` package
|
|
8
|
+
(``pip install tina4-python[push]``). Both backends emit identical bytes, so the
|
|
9
|
+
wire output is the same whichever runs. Set ``TINA4_PUSH_BACKEND`` to ``libcrypto``
|
|
10
|
+
or ``cryptography`` to pin one; ``auto`` (default) prefers libcrypto.
|
|
6
11
|
"""
|
|
7
12
|
from __future__ import annotations
|
|
8
13
|
|
|
@@ -19,6 +24,8 @@ import urllib.request
|
|
|
19
24
|
from dataclasses import dataclass
|
|
20
25
|
from typing import Any
|
|
21
26
|
|
|
27
|
+
from ._base import BackendUnavailable
|
|
28
|
+
|
|
22
29
|
RECORD_SIZE = 4096
|
|
23
30
|
MAX_PAYLOAD = RECORD_SIZE - 17
|
|
24
31
|
|
|
@@ -50,15 +57,45 @@ def _unb64(value: str, name: str) -> bytes:
|
|
|
50
57
|
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
51
58
|
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
_BACKEND = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _select_backend():
|
|
64
|
+
"""Pick the crypto backend: OS libcrypto first, cryptography as fallback.
|
|
65
|
+
|
|
66
|
+
``TINA4_PUSH_BACKEND`` forces ``auto`` (default), ``libcrypto`` or
|
|
67
|
+
``cryptography``. The choice is cached for the process; ``_reset_backend``
|
|
68
|
+
drops the cache so a later call re-selects after the environment changes.
|
|
69
|
+
"""
|
|
70
|
+
global _BACKEND
|
|
71
|
+
if _BACKEND is not None:
|
|
72
|
+
return _BACKEND
|
|
73
|
+
choice = os.getenv("TINA4_PUSH_BACKEND", "auto").strip().lower() or "auto"
|
|
74
|
+
order = {"auto": ("libcrypto", "cryptography"),
|
|
75
|
+
"libcrypto": ("libcrypto",),
|
|
76
|
+
"cryptography": ("cryptography",)}.get(choice)
|
|
77
|
+
if order is None:
|
|
78
|
+
raise PushError("TINA4_PUSH_BACKEND must be auto, libcrypto or cryptography")
|
|
79
|
+
reasons = []
|
|
80
|
+
for name in order:
|
|
81
|
+
if name == "libcrypto":
|
|
82
|
+
from . import _libcrypto as backend_module
|
|
83
|
+
else:
|
|
84
|
+
from . import _cryptography as backend_module
|
|
85
|
+
try:
|
|
86
|
+
_BACKEND = backend_module.load()
|
|
87
|
+
return _BACKEND
|
|
88
|
+
except BackendUnavailable as exc:
|
|
89
|
+
reasons.append(f"{name} ({exc})")
|
|
90
|
+
raise PushError(
|
|
91
|
+
"Web Push needs the OS OpenSSL (Linux/Unix) or the cryptography package "
|
|
92
|
+
"(install tina4-python[push]); unavailable here: " + ", ".join(reasons))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _reset_backend() -> None:
|
|
96
|
+
"""Drop the cached backend so the next call re-selects."""
|
|
97
|
+
global _BACKEND
|
|
98
|
+
_BACKEND = None
|
|
62
99
|
|
|
63
100
|
|
|
64
101
|
def _hmac(key: bytes, value: bytes) -> bytes:
|
|
@@ -78,15 +115,8 @@ def _hkdf(prk: bytes, info: bytes, length: int) -> bytes:
|
|
|
78
115
|
return b"".join(chunks)[:length]
|
|
79
116
|
|
|
80
117
|
|
|
81
|
-
def _public_bytes(public_key: Any, serialization: Any) -> bytes:
|
|
82
|
-
return public_key.public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
|
|
83
|
-
|
|
84
|
-
|
|
85
118
|
def generate_vapid_keys() -> dict[str, str]:
|
|
86
|
-
|
|
87
|
-
private = ec.generate_private_key(ec.SECP256R1())
|
|
88
|
-
public = _public_bytes(private.public_key(), serialization)
|
|
89
|
-
raw_private = private.private_numbers().private_value.to_bytes(32, "big")
|
|
119
|
+
raw_private, public = _select_backend().generate_keypair()
|
|
90
120
|
return {"publicKey": _b64(public), "privateKey": _b64(raw_private)}
|
|
91
121
|
|
|
92
122
|
|
|
@@ -104,7 +134,7 @@ def _payload_bytes(payload: Any) -> bytes:
|
|
|
104
134
|
def _encrypt(payload: bytes, subscription: dict[str, Any]) -> bytes:
|
|
105
135
|
if len(payload) > MAX_PAYLOAD:
|
|
106
136
|
raise PushError(f"Push payload is too large; maximum is {MAX_PAYLOAD} bytes")
|
|
107
|
-
|
|
137
|
+
backend = _select_backend()
|
|
108
138
|
try:
|
|
109
139
|
client_public_bytes = _unb64(subscription["keys"]["p256dh"], "subscription.keys.p256dh")
|
|
110
140
|
auth_secret = _unb64(subscription["keys"]["auth"], "subscription.keys.auth")
|
|
@@ -115,32 +145,27 @@ def _encrypt(payload: bytes, subscription: dict[str, Any]) -> bytes:
|
|
|
115
145
|
if len(auth_secret) != 16:
|
|
116
146
|
raise PushError("subscription.keys.auth must be a 16-byte authentication secret")
|
|
117
147
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
148
|
+
try:
|
|
149
|
+
server_public_bytes, shared = backend.encrypt_ecdh(client_public_bytes)
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
raise PushError("subscription.keys.p256dh is not a valid P-256 public key") from exc
|
|
122
152
|
key_info = b"WebPush: info\0" + client_public_bytes + server_public_bytes
|
|
123
153
|
ikm = _hkdf(_hmac(auth_secret, shared), key_info, 32)
|
|
124
154
|
salt = os.urandom(16)
|
|
125
155
|
prk = _hmac(salt, ikm)
|
|
126
156
|
cek = _hkdf(prk, b"Content-Encoding: aes128gcm\0", 16)
|
|
127
157
|
nonce = _hkdf(prk, b"Content-Encoding: nonce\0", 12)
|
|
128
|
-
|
|
129
|
-
ciphertext = encryptor.update(payload + b"\x02") + encryptor.finalize() + encryptor.tag
|
|
158
|
+
ciphertext = backend.aes128gcm_encrypt(cek, nonce, payload + b"\x02")
|
|
130
159
|
return salt + RECORD_SIZE.to_bytes(4, "big") + bytes([len(server_public_bytes)]) + server_public_bytes + ciphertext
|
|
131
160
|
|
|
132
161
|
|
|
133
162
|
def _vapid_token(endpoint: str, subject: str, raw_private: bytes, raw_public: bytes) -> str:
|
|
134
|
-
_hashes, serialization, ec, _Cipher, _algorithms, _modes, decode_dss_signature = _crypto()
|
|
135
163
|
audience = urllib.parse.urlparse(endpoint)
|
|
136
164
|
aud = f"{audience.scheme}://{audience.netloc}"
|
|
137
165
|
header = _b64(json.dumps({"typ": "JWT", "alg": "ES256"}, separators=(",", ":")).encode())
|
|
138
166
|
claims = _b64(json.dumps({"aud": aud, "exp": int(time.time()) + 12 * 60 * 60, "sub": subject}, separators=(",", ":")).encode())
|
|
139
167
|
signing_input = f"{header}.{claims}".encode("ascii")
|
|
140
|
-
|
|
141
|
-
der = private.sign(signing_input, ec.ECDSA(_hashes.SHA256()))
|
|
142
|
-
r, s = decode_dss_signature(der)
|
|
143
|
-
signature = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
|
168
|
+
signature = _select_backend().sign_es256(raw_private, signing_input)
|
|
144
169
|
return signing_input.decode("ascii") + "." + _b64(signature)
|
|
145
170
|
|
|
146
171
|
|
|
@@ -196,12 +221,11 @@ class Push:
|
|
|
196
221
|
raise PushError("TINA4_VAPID_PUBLIC must be a 65-byte P-256 public key")
|
|
197
222
|
if len(raw_private) != 32:
|
|
198
223
|
raise PushError("TINA4_VAPID_PRIVATE must be a 32-byte P-256 private key")
|
|
199
|
-
_hashes, serialization, ec, *_ = _crypto()
|
|
200
224
|
try:
|
|
201
|
-
|
|
225
|
+
derived_public = _select_backend().public_from_private(raw_private)
|
|
202
226
|
except ValueError as exc:
|
|
203
227
|
raise PushError("TINA4_VAPID_PRIVATE is not a valid P-256 private key") from exc
|
|
204
|
-
if
|
|
228
|
+
if derived_public != raw_public:
|
|
205
229
|
raise PushError("TINA4_VAPID_PUBLIC does not match TINA4_VAPID_PRIVATE")
|
|
206
230
|
return raw_public, raw_private
|
|
207
231
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The Web Push crypto backend contract.
|
|
2
|
+
|
|
3
|
+
A backend supplies the five primitives RFC 8291 / RFC 8292 need that CPython's
|
|
4
|
+
stdlib cannot: P-256 keygen, ECDH, AES-128-GCM, the public point of a stored
|
|
5
|
+
scalar, and an ES256 signature. Everything else (HKDF, HMAC, the record framing,
|
|
6
|
+
the JWT assembly) is pure stdlib and lives in ``__init__``, written once and
|
|
7
|
+
shared by both backends.
|
|
8
|
+
|
|
9
|
+
Two backends implement this contract: ``_libcrypto`` (ctypes -> the OS OpenSSL,
|
|
10
|
+
zero packages, the server path) and ``_cryptography`` (the pyca package, the
|
|
11
|
+
Mac/Windows dev fallback). Both return the SAME bytes, so the wire output stays
|
|
12
|
+
identical to the PHP, Ruby and Node frameworks whichever one runs.
|
|
13
|
+
|
|
14
|
+
Each method raises ``ValueError`` on malformed key material. A backend that
|
|
15
|
+
cannot run on this host raises ``BackendUnavailable`` from its ``load()`` so the
|
|
16
|
+
selector can fall through to the next one.
|
|
17
|
+
|
|
18
|
+
Contract (all keys are raw bytes, points are 65-byte uncompressed ``0x04`` form):
|
|
19
|
+
|
|
20
|
+
generate_keypair() -> (raw_private32, public65)
|
|
21
|
+
A fresh random P-256 keypair.
|
|
22
|
+
public_from_private(raw_private32) -> public65
|
|
23
|
+
The public point for a stored scalar (used to validate a VAPID pair).
|
|
24
|
+
encrypt_ecdh(peer_public65) -> (server_public65, shared32)
|
|
25
|
+
Generate an ephemeral keypair, ECDH against the subscriber's key, and
|
|
26
|
+
hand back both the ephemeral public point and the shared secret. The
|
|
27
|
+
ephemeral private key never leaves the backend.
|
|
28
|
+
aes128gcm_encrypt(key16, iv12, plaintext) -> ciphertext_with_tag
|
|
29
|
+
AES-128-GCM; the 16-byte tag is appended to the ciphertext.
|
|
30
|
+
sign_es256(raw_private32, message) -> signature64
|
|
31
|
+
ECDSA P-256 / SHA-256 over ``message``, as raw r||s (each left-padded
|
|
32
|
+
to 32 bytes).
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BackendUnavailable(RuntimeError):
|
|
38
|
+
"""Raised by a backend's ``load()`` when it cannot run on this host."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Web Push crypto through the pyca ``cryptography`` package.
|
|
2
|
+
|
|
3
|
+
The dev fallback. When ctypes cannot reach a safe OS OpenSSL - a stock Mac, a
|
|
4
|
+
Windows workstation, a musl image without OpenSSL - this backend runs instead,
|
|
5
|
+
provided the optional package is installed (``pip install tina4-python[push]``).
|
|
6
|
+
It produces the same bytes as the libcrypto backend, so the wire output does not
|
|
7
|
+
change with the host.
|
|
8
|
+
|
|
9
|
+
``load()`` raises ``BackendUnavailable`` when the package is absent, so the
|
|
10
|
+
selector can report both misses in one clear error.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ._base import BackendUnavailable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CryptographyBackend:
|
|
18
|
+
"""RFC 8291 / RFC 8292 primitives over the pyca cryptography package."""
|
|
19
|
+
|
|
20
|
+
name = "cryptography"
|
|
21
|
+
|
|
22
|
+
def __init__(self, module_bundle):
|
|
23
|
+
(self._ec, self._AESGCM, self._Encoding, self._PublicFormat,
|
|
24
|
+
self._hashes, self._decode_dss) = module_bundle
|
|
25
|
+
|
|
26
|
+
def _public_bytes(self, public_key) -> bytes:
|
|
27
|
+
return public_key.public_bytes(self._Encoding.X962, self._PublicFormat.UncompressedPoint)
|
|
28
|
+
|
|
29
|
+
def generate_keypair(self):
|
|
30
|
+
priv = self._ec.generate_private_key(self._ec.SECP256R1())
|
|
31
|
+
pub = self._public_bytes(priv.public_key())
|
|
32
|
+
raw = priv.private_numbers().private_value.to_bytes(32, "big")
|
|
33
|
+
return raw, pub
|
|
34
|
+
|
|
35
|
+
def public_from_private(self, raw_private: bytes) -> bytes:
|
|
36
|
+
priv = self._ec.derive_private_key(int.from_bytes(raw_private, "big"), self._ec.SECP256R1())
|
|
37
|
+
return self._public_bytes(priv.public_key())
|
|
38
|
+
|
|
39
|
+
def encrypt_ecdh(self, peer_public: bytes):
|
|
40
|
+
peer = self._ec.EllipticCurvePublicKey.from_encoded_point(self._ec.SECP256R1(), peer_public)
|
|
41
|
+
ephemeral = self._ec.generate_private_key(self._ec.SECP256R1())
|
|
42
|
+
server_pub = self._public_bytes(ephemeral.public_key())
|
|
43
|
+
shared = ephemeral.exchange(self._ec.ECDH(), peer)
|
|
44
|
+
return server_pub, shared
|
|
45
|
+
|
|
46
|
+
def aes128gcm_encrypt(self, key: bytes, iv: bytes, plaintext: bytes) -> bytes:
|
|
47
|
+
return self._AESGCM(key).encrypt(iv, plaintext, None)
|
|
48
|
+
|
|
49
|
+
def sign_es256(self, raw_private: bytes, message: bytes) -> bytes:
|
|
50
|
+
priv = self._ec.derive_private_key(int.from_bytes(raw_private, "big"), self._ec.SECP256R1())
|
|
51
|
+
der = priv.sign(message, self._ec.ECDSA(self._hashes.SHA256()))
|
|
52
|
+
r, s = self._decode_dss(der)
|
|
53
|
+
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load() -> CryptographyBackend:
|
|
57
|
+
"""Return the cryptography backend, or raise BackendUnavailable if absent."""
|
|
58
|
+
try:
|
|
59
|
+
from cryptography.hazmat.primitives import hashes
|
|
60
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
61
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
|
62
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
63
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
64
|
+
except ImportError as exc:
|
|
65
|
+
raise BackendUnavailable("the cryptography package is not installed") from exc
|
|
66
|
+
return CryptographyBackend((ec, AESGCM, Encoding, PublicFormat, hashes, decode_dss_signature))
|