tina4-python 3.13.95__tar.gz → 3.13.97__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.95 → tina4_python-3.13.97}/PKG-INFO +2 -1
- {tina4_python-3.13.95 → tina4_python-3.13.97}/pyproject.toml +5 -1
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/CLAUDE.md +1 -1
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/__init__.py +2 -1
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/api/__init__.py +10 -7
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/server.py +198 -11
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/crud/__init__.py +10 -11
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/adapter.py +54 -59
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/messenger/__init__.py +158 -79
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/migration/runner.py +29 -2
- tina4_python-3.13.97/tina4_python/public/css/tina4.min.css +1 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/kafka_backend.py +31 -2
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/rabbitmq_backend.py +30 -4
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/swagger/__init__.py +6 -3
- tina4_python-3.13.95/tina4_python/public/css/tina4.min.css +0 -1
- tina4_python-3.13.95/tina4_python/scss/__init__.py +0 -694
- {tina4_python-3.13.95 → tina4_python-3.13.97}/.gitignore +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/README.md +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/sql_translator.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/env.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_pagination.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.95 → tina4_python-3.13.97}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tina4-python
|
|
3
|
-
Version: 3.13.
|
|
3
|
+
Version: 3.13.97
|
|
4
4
|
Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
|
|
5
5
|
Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -39,6 +39,7 @@ Requires-Dist: boto3>=1.34; extra == 'test'
|
|
|
39
39
|
Requires-Dist: confluent-kafka>=2.0; extra == 'test'
|
|
40
40
|
Requires-Dist: firebird-driver>=1.10; extra == 'test'
|
|
41
41
|
Requires-Dist: mysql-connector-python>=9.0; extra == 'test'
|
|
42
|
+
Requires-Dist: openapi-spec-validator>=0.7; extra == 'test'
|
|
42
43
|
Requires-Dist: pika>=1.3; extra == 'test'
|
|
43
44
|
Requires-Dist: psycopg2-binary>=2.9; extra == 'test'
|
|
44
45
|
Requires-Dist: pymemcache>=4.0; extra == 'test'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tina4-python"
|
|
3
|
-
version = "3.13.
|
|
3
|
+
version = "3.13.97"
|
|
4
4
|
description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
|
|
@@ -81,6 +81,10 @@ test = [
|
|
|
81
81
|
"confluent-kafka>=2.0",
|
|
82
82
|
"pika>=1.3",
|
|
83
83
|
"boto3>=1.34",
|
|
84
|
+
# TEST-ONLY: validates the generated OpenAPI document against the real
|
|
85
|
+
# OpenAPI 3.0/3.1 schema (tests/test_swagger_contract.py). NEVER a framework
|
|
86
|
+
# runtime dependency — the swagger generator itself stays zero-dependency.
|
|
87
|
+
"openapi-spec-validator>=0.7",
|
|
84
88
|
]
|
|
85
89
|
|
|
86
90
|
[dependency-groups]
|
|
@@ -821,7 +821,7 @@ row = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
|
|
|
821
821
|
# Result methods
|
|
822
822
|
result.to_json() # JSON string
|
|
823
823
|
result.to_array() # List of dicts
|
|
824
|
-
result.to_paginate() # Dict
|
|
824
|
+
result.to_paginate() # Dict: records, total, page, per_page, total_pages, limit, offset (takes no args; ADR-0043)
|
|
825
825
|
result.to_csv() # CSV string
|
|
826
826
|
|
|
827
827
|
# Transactions
|
|
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
|
|
|
51
51
|
#
|
|
52
52
|
# test_version_constant.py now asserts this literal equals the pyproject
|
|
53
53
|
# version, so the release bump cannot leave it behind again.
|
|
54
|
-
return "3.13.
|
|
54
|
+
return "3.13.97"
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
__version__ = _resolve_version()
|
|
@@ -86,6 +86,7 @@ from tina4_python.core.constants import ( # noqa: E402, F401
|
|
|
86
86
|
)
|
|
87
87
|
from tina4_python.core.server import ( # noqa: E402, F401
|
|
88
88
|
run, background, background_task_count, stop_all_background_tasks,
|
|
89
|
+
asgi,
|
|
89
90
|
)
|
|
90
91
|
from tina4_python.core.events import on, emit, once, off # noqa: E402, F401
|
|
91
92
|
from tina4_python.env import Env # noqa: E402, F401
|
|
@@ -203,13 +203,16 @@ class Api:
|
|
|
203
203
|
"""HTTP DELETE request."""
|
|
204
204
|
return self._request("DELETE", self._url(path), body)
|
|
205
205
|
|
|
206
|
-
def
|
|
207
|
-
|
|
208
|
-
"""Generic request method — pick HTTP verb at call time.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
206
|
+
def send_request(self, method: str, path: str = "", body=None,
|
|
207
|
+
content_type: str = "application/json") -> dict:
|
|
208
|
+
"""Generic request method — pick the HTTP verb at call time.
|
|
209
|
+
|
|
210
|
+
``send_request`` is the name all four frameworks share (PHP
|
|
211
|
+
``sendRequest``, Ruby ``send_request``, Node ``sendRequest``). The
|
|
212
|
+
3.13.0 Python-only rename to ``send`` was reverted: ``send`` can never
|
|
213
|
+
be the shared name because Ruby's ``send`` is ``Object#send``, the
|
|
214
|
+
metaprogramming primitive, so a Ruby class cannot expose an HTTP
|
|
215
|
+
``send`` without shadowing it. One concept, one name, everywhere.
|
|
213
216
|
"""
|
|
214
217
|
return self._request(method.upper(), self._url(path), body, content_type)
|
|
215
218
|
|
|
@@ -17,7 +17,11 @@ import uuid
|
|
|
17
17
|
from email.utils import formatdate, parsedate_to_datetime
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
from tina4_python.core.request import
|
|
20
|
+
from tina4_python.core.request import (
|
|
21
|
+
Request,
|
|
22
|
+
PayloadTooLarge,
|
|
23
|
+
TINA4_MAX_UPLOAD_SIZE,
|
|
24
|
+
)
|
|
21
25
|
from tina4_python.core.response import Response
|
|
22
26
|
from tina4_python.core.router import Router
|
|
23
27
|
from tina4_python.core.middleware import CorsMiddleware, RateLimiter
|
|
@@ -1867,10 +1871,42 @@ async def _invoke_handler(request: Request, response: Response, route: dict, par
|
|
|
1867
1871
|
_args.append(request)
|
|
1868
1872
|
_args.append(response)
|
|
1869
1873
|
|
|
1870
|
-
|
|
1871
|
-
|
|
1874
|
+
# A SYNC HANDLER RUNS IN A THREAD, AN ASYNC ONE RUNS ON THE LOOP.
|
|
1875
|
+
#
|
|
1876
|
+
# This used to `await` the handler unconditionally, which had two costs.
|
|
1877
|
+
# A plain `def` handler died with "object Response can't be used in 'await'
|
|
1878
|
+
# expression" - a message naming neither the handler nor the rule - even
|
|
1879
|
+
# though Flask, Django and FastAPI all accept a sync view, so `def` is the
|
|
1880
|
+
# first thing a Python developer writes. And there was nowhere for blocking
|
|
1881
|
+
# work to go: asyncio runs ONE loop, so anything that blocks it stops every
|
|
1882
|
+
# other in-flight request. Measured on 3.13.94 with time.sleep(10) in a
|
|
1883
|
+
# route, a trivial route took 9.004s instead of milliseconds.
|
|
1884
|
+
#
|
|
1885
|
+
# Threading the sync path fixes both at once, and is what Starlette and
|
|
1886
|
+
# FastAPI already do. It is also Python's answer to the same problem PHP
|
|
1887
|
+
# solved by forking per request: PHP has no threads, Python does.
|
|
1888
|
+
#
|
|
1889
|
+
# The check happens BEFORE the call, deliberately. Calling first and then
|
|
1890
|
+
# asking whether the result is awaitable would already have run the sync
|
|
1891
|
+
# handler on the loop, which is the thing being avoided.
|
|
1892
|
+
#
|
|
1893
|
+
# A callable OBJECT with an async __call__ reports False from
|
|
1894
|
+
# iscoroutinefunction(), so its __call__ is checked too - otherwise such a
|
|
1895
|
+
# handler would be sent to a thread and its coroutine never awaited.
|
|
1896
|
+
_handler = route["handler"]
|
|
1897
|
+
_is_async = inspect.iscoroutinefunction(_handler) or (
|
|
1898
|
+
not inspect.isfunction(_handler)
|
|
1899
|
+
and not inspect.ismethod(_handler)
|
|
1900
|
+
and inspect.iscoroutinefunction(getattr(_handler, "__call__", None))
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
if _is_async:
|
|
1904
|
+
result = await (_handler() if _pcount == 0 else _handler(*_args))
|
|
1905
|
+
elif _pcount == 0:
|
|
1906
|
+
result = await asyncio.to_thread(_handler)
|
|
1872
1907
|
else:
|
|
1873
|
-
result = await
|
|
1908
|
+
result = await asyncio.to_thread(_handler, *_args)
|
|
1909
|
+
|
|
1874
1910
|
if isinstance(result, Response):
|
|
1875
1911
|
response = result
|
|
1876
1912
|
return response
|
|
@@ -2549,6 +2585,62 @@ async def handle(request: Request) -> Response:
|
|
|
2549
2585
|
|
|
2550
2586
|
return ctx.response
|
|
2551
2587
|
|
|
2588
|
+
def asgi(root_dir: str = "src"):
|
|
2589
|
+
"""Build the ASGI application, with routes discovered.
|
|
2590
|
+
|
|
2591
|
+
Point any ASGI server at this - uvicorn, hypercorn, granian, daphne - when
|
|
2592
|
+
you want that server's process model instead of ``tina4 serve``:
|
|
2593
|
+
|
|
2594
|
+
# asgi.py
|
|
2595
|
+
from tina4_python.core.server import asgi
|
|
2596
|
+
app = asgi()
|
|
2597
|
+
|
|
2598
|
+
$ uvicorn asgi:app --workers 4
|
|
2599
|
+
|
|
2600
|
+
THE DISCOVERY IS THE POINT. Pointing a server straight at ``app`` below
|
|
2601
|
+
starts a working ASGI application with NO ROUTES REGISTERED, because
|
|
2602
|
+
discovery happens inside ``run()`` and nothing else calls it. Measured: the
|
|
2603
|
+
bare app answers /hello with a 404 page, and the same app behind this
|
|
2604
|
+
function answers 200. That failure is quiet - the server boots, the health
|
|
2605
|
+
page renders, and every application route 404s - so the framework supplies
|
|
2606
|
+
the bootstrap rather than leaving each user to find ``_auto_discover``,
|
|
2607
|
+
which is private and has no business in a deployment file.
|
|
2608
|
+
|
|
2609
|
+
:param root_dir: Directory to discover routes from. Defaults to ``src``.
|
|
2610
|
+
:return: The ASGI 3 callable.
|
|
2611
|
+
"""
|
|
2612
|
+
_auto_discover(root_dir)
|
|
2613
|
+
return app
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
async def _send_payload_too_large(send, received: int, limit: int) -> None:
|
|
2617
|
+
"""Answer 413 for a body over TINA4_MAX_UPLOAD_SIZE.
|
|
2618
|
+
|
|
2619
|
+
Written straight to the ASGI `send` rather than routed through the normal
|
|
2620
|
+
response path: the request never became a Request object, so there is no
|
|
2621
|
+
route, no middleware and no session to run it through.
|
|
2622
|
+
"""
|
|
2623
|
+
import json
|
|
2624
|
+
|
|
2625
|
+
payload = json.dumps(
|
|
2626
|
+
{
|
|
2627
|
+
"error": f"Request body ({received} bytes) exceeds "
|
|
2628
|
+
f"TINA4_MAX_UPLOAD_SIZE ({limit} bytes)"
|
|
2629
|
+
}
|
|
2630
|
+
).encode("utf-8")
|
|
2631
|
+
await send(
|
|
2632
|
+
{
|
|
2633
|
+
"type": "http.response.start",
|
|
2634
|
+
"status": 413,
|
|
2635
|
+
"headers": [
|
|
2636
|
+
[b"content-type", b"application/json"],
|
|
2637
|
+
[b"content-length", str(len(payload)).encode()],
|
|
2638
|
+
],
|
|
2639
|
+
}
|
|
2640
|
+
)
|
|
2641
|
+
await send({"type": "http.response.body", "body": payload, "more_body": False})
|
|
2642
|
+
|
|
2643
|
+
|
|
2552
2644
|
async def app(scope: dict, receive, send):
|
|
2553
2645
|
"""ASGI entry point — compatible with uvicorn, hypercorn, granian."""
|
|
2554
2646
|
if scope["type"] == "lifespan":
|
|
@@ -2584,16 +2676,74 @@ async def app(scope: dict, receive, send):
|
|
|
2584
2676
|
if scope["type"] != "http":
|
|
2585
2677
|
return
|
|
2586
2678
|
|
|
2587
|
-
# Read
|
|
2588
|
-
|
|
2679
|
+
# Read the body, bounded, in one allocation.
|
|
2680
|
+
#
|
|
2681
|
+
# Two defects lived in the three lines this replaces.
|
|
2682
|
+
#
|
|
2683
|
+
# `body += chunk` on immutable bytes reallocates and copies the WHOLE
|
|
2684
|
+
# buffer per chunk, so an N-byte body costs O(N^2). Measured with uvicorn
|
|
2685
|
+
# feeding ~16KB chunks: a 40MB upload took the server from 51MB RSS to
|
|
2686
|
+
# 1213MB. A list plus one join is linear.
|
|
2687
|
+
#
|
|
2688
|
+
# And the size limit was enforced by Request.from_scope AFTER this loop
|
|
2689
|
+
# finished, so a body 4x over the limit was accumulated in full and only
|
|
2690
|
+
# then refused - the limit measured the damage instead of preventing it.
|
|
2691
|
+
# Checking per chunk stops at the limit. (Same defect, same fix, as the
|
|
2692
|
+
# Node request reader.)
|
|
2693
|
+
# Refuse on the DECLARED length before reading a byte. A client that
|
|
2694
|
+
# announces 32MB against a 1MB limit gets its answer immediately instead of
|
|
2695
|
+
# uploading 32MB first; and a client that announces a large body and then
|
|
2696
|
+
# sends almost nothing would otherwise hold the connection while the server
|
|
2697
|
+
# waits for the rest. This is what a reverse proxy does in front of you.
|
|
2698
|
+
declared = 0
|
|
2699
|
+
for _name, _value in scope.get("headers", []):
|
|
2700
|
+
if _name.lower() == b"content-length":
|
|
2701
|
+
try:
|
|
2702
|
+
declared = int(_value)
|
|
2703
|
+
except (TypeError, ValueError):
|
|
2704
|
+
declared = 0
|
|
2705
|
+
break
|
|
2706
|
+
if declared > TINA4_MAX_UPLOAD_SIZE:
|
|
2707
|
+
await _send_payload_too_large(send, declared, TINA4_MAX_UPLOAD_SIZE)
|
|
2708
|
+
return
|
|
2709
|
+
|
|
2710
|
+
chunks = []
|
|
2711
|
+
received = 0
|
|
2712
|
+
too_large = False
|
|
2589
2713
|
while True:
|
|
2590
2714
|
msg = await receive()
|
|
2591
|
-
|
|
2715
|
+
chunk = msg.get("body", b"")
|
|
2716
|
+
if chunk and not too_large:
|
|
2717
|
+
received += len(chunk)
|
|
2718
|
+
if received > TINA4_MAX_UPLOAD_SIZE:
|
|
2719
|
+
# Stop accumulating, but keep draining: an ASGI server expects
|
|
2720
|
+
# the request stream to be consumed, and abandoning it mid-body
|
|
2721
|
+
# can wedge the connection.
|
|
2722
|
+
too_large = True
|
|
2723
|
+
chunks = []
|
|
2724
|
+
else:
|
|
2725
|
+
chunks.append(chunk)
|
|
2592
2726
|
if not msg.get("more_body", False):
|
|
2593
2727
|
break
|
|
2594
2728
|
|
|
2729
|
+
if too_large:
|
|
2730
|
+
# 413, not 500. PayloadTooLarge was raised and caught by nobody, so an
|
|
2731
|
+
# oversized upload answered "Internal Server Error" - which tells the
|
|
2732
|
+
# caller to retry the request that will fail again.
|
|
2733
|
+
await _send_payload_too_large(send, received, TINA4_MAX_UPLOAD_SIZE)
|
|
2734
|
+
return
|
|
2735
|
+
|
|
2736
|
+
body = b"".join(chunks)
|
|
2737
|
+
|
|
2595
2738
|
# Build request and dispatch
|
|
2596
|
-
|
|
2739
|
+
try:
|
|
2740
|
+
request = Request.from_scope(scope, body)
|
|
2741
|
+
except PayloadTooLarge:
|
|
2742
|
+
# Still reachable: from_scope also refuses on a DECLARED content-length
|
|
2743
|
+
# over the limit, which the loop above never sees when the client lies
|
|
2744
|
+
# about the length or sends nothing.
|
|
2745
|
+
await _send_payload_too_large(send, received, TINA4_MAX_UPLOAD_SIZE)
|
|
2746
|
+
return
|
|
2597
2747
|
response = await handle(request)
|
|
2598
2748
|
|
|
2599
2749
|
# Streaming responses bypass ETag/compression — send immediately
|
|
@@ -2960,16 +3110,53 @@ def resolve_config(cli_host: str | None = None, cli_port: int | None = None) ->
|
|
|
2960
3110
|
else:
|
|
2961
3111
|
host = os.environ.get("TINA4_HOST") or os.environ.get("HOST", default_host)
|
|
2962
3112
|
|
|
2963
|
-
# Port: CLI flag > PORT
|
|
3113
|
+
# Port: CLI flag > TINA4_PORT > PORT (deprecated) > default.
|
|
3114
|
+
#
|
|
3115
|
+
# This used to read PORT and nothing else, so TINA4_PORT - the name the CLI
|
|
3116
|
+
# documents and prefers, and the one every .env sets - was IGNORED on the
|
|
3117
|
+
# one path that binds the socket. An operator setting it saw the server
|
|
3118
|
+
# come up on the default port with no complaint. It cost two benchmark runs
|
|
3119
|
+
# in one afternoon before anyone read this function.
|
|
3120
|
+
#
|
|
3121
|
+
# Bare PORT is deprecated: it is a name anything can set, and a stray
|
|
3122
|
+
# OS-level PORT on a shared runner should never outrank the framework's own
|
|
3123
|
+
# variable. It is still honoured so no deployment breaks, and warns so the
|
|
3124
|
+
# migration actually happens. Removal is 3.14.
|
|
2964
3125
|
if cli_port is not None:
|
|
2965
3126
|
port = cli_port
|
|
2966
3127
|
else:
|
|
2967
|
-
|
|
2968
|
-
|
|
3128
|
+
tina4_port = os.environ.get("TINA4_PORT")
|
|
3129
|
+
legacy_port = os.environ.get("PORT")
|
|
3130
|
+
if tina4_port and tina4_port.isdigit():
|
|
3131
|
+
port = int(tina4_port)
|
|
3132
|
+
elif legacy_port and legacy_port.isdigit():
|
|
3133
|
+
port = int(legacy_port)
|
|
3134
|
+
_warn_deprecated_port(port)
|
|
3135
|
+
else:
|
|
3136
|
+
port = default_port
|
|
2969
3137
|
|
|
2970
3138
|
return host, port
|
|
2971
3139
|
|
|
2972
3140
|
|
|
3141
|
+
_PORT_DEPRECATION_WARNED = False
|
|
3142
|
+
|
|
3143
|
+
|
|
3144
|
+
def _warn_deprecated_port(port: int) -> None:
|
|
3145
|
+
"""Warn ONCE that bare PORT was used instead of TINA4_PORT.
|
|
3146
|
+
|
|
3147
|
+
Once, because resolve_config can be called more than once per process and
|
|
3148
|
+
a warning repeated on every call is a warning people filter out.
|
|
3149
|
+
"""
|
|
3150
|
+
global _PORT_DEPRECATION_WARNED
|
|
3151
|
+
if _PORT_DEPRECATION_WARNED:
|
|
3152
|
+
return
|
|
3153
|
+
_PORT_DEPRECATION_WARNED = True
|
|
3154
|
+
Log.warning(
|
|
3155
|
+
f"PORT is deprecated and will be removed in 3.14 - use TINA4_PORT "
|
|
3156
|
+
f"instead (binding port {port} from PORT)"
|
|
3157
|
+
)
|
|
3158
|
+
|
|
3159
|
+
|
|
2973
3160
|
def banner_surface_lines(
|
|
2974
3161
|
port: int, *, swagger_enabled: bool, dev_admin_enabled: bool
|
|
2975
3162
|
) -> tuple[str, str]:
|
|
@@ -119,18 +119,17 @@ class AutoCrud:
|
|
|
119
119
|
records = _cls.all(limit=limit, offset=offset)
|
|
120
120
|
total = _cls.count()
|
|
121
121
|
total_pages = max(1, -(-total // limit)) if limit else 1
|
|
122
|
-
|
|
122
|
+
record_dicts = [record.to_dict() for record in records]
|
|
123
|
+
# The canonical ADR-0043 envelope: EXACTLY seven snake_case keys, no
|
|
124
|
+
# duplicate or camelCase spellings, matching DatabaseResult.to_paginate().
|
|
123
125
|
return response({
|
|
124
|
-
"records":
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"per_page": limit, # backwards compat
|
|
132
|
-
"totalPages": total_pages, # camelCase standard
|
|
133
|
-
"total_pages": total_pages, # backwards compat
|
|
126
|
+
"records": record_dicts, # the page rows, verbatim (never re-sliced)
|
|
127
|
+
"total": total, # the true COUNT for the filter, NOT rows returned
|
|
128
|
+
"page": page, # floor(offset / limit) + 1
|
|
129
|
+
"per_page": limit, # the query's limit
|
|
130
|
+
"total_pages": total_pages, # ceil(total / per_page)
|
|
131
|
+
"limit": limit, # the SQL limit actually applied
|
|
132
|
+
"offset": offset, # the SQL offset actually applied
|
|
134
133
|
})
|
|
135
134
|
|
|
136
135
|
list_handler.__name__ = f"autocrud_list_{table}"
|
|
@@ -263,71 +263,66 @@ class DatabaseResult:
|
|
|
263
263
|
rows = [",".join(escape(row.get(c)) for c in columns) for row in self.records]
|
|
264
264
|
return "\n".join([header] + rows)
|
|
265
265
|
|
|
266
|
-
def to_paginate(self,
|
|
267
|
-
"""
|
|
268
|
-
|
|
269
|
-
NO
|
|
270
|
-
|
|
266
|
+
def to_paginate(self, *args, **kwargs) -> dict:
|
|
267
|
+
"""Describe the page this result already IS, as the canonical envelope.
|
|
268
|
+
|
|
269
|
+
Takes NO arguments. Every field is derived from the query that
|
|
270
|
+
produced this result (ADR-0043):
|
|
271
|
+
|
|
272
|
+
per_page = the query's limit
|
|
273
|
+
page = floor(offset / limit) + 1
|
|
274
|
+
total = the true total for the filter, the COUNT probe that
|
|
275
|
+
populates .count, NEVER the number of rows returned
|
|
276
|
+
total_pages = ceil(total / per_page)
|
|
277
|
+
records = the rows the query returned, VERBATIM, never re-sliced
|
|
278
|
+
limit = the SQL limit actually applied
|
|
279
|
+
offset = the SQL offset actually applied
|
|
280
|
+
|
|
281
|
+
The envelope is EXACTLY seven snake_case keys (records, total, page,
|
|
282
|
+
per_page, total_pages, limit, offset), identical in all four Tina4
|
|
283
|
+
frameworks. A JSON key is data and does not change spelling by host
|
|
284
|
+
language, so there is no camelCase and no duplicate spelling: the old
|
|
285
|
+
``data``, ``count`` and ``totalPages`` aliases are gone.
|
|
286
|
+
|
|
287
|
+
Passing ANY argument RAISES. The method used to accept (page, per_page)
|
|
288
|
+
and slice the rows in memory (GitHub issue #106), but a DatabaseResult
|
|
289
|
+
holds no connection, so an argument can only re-slice the rows already
|
|
290
|
+
in memory and then report total_pages for pages it can never reach --
|
|
291
|
+
the measured Ruby defect where slicing offset 40 into a 20-row page
|
|
292
|
+
returned NOTHING while still naming a page and a total. To read a
|
|
293
|
+
different page, FETCH that page (limit + offset) and call this with no
|
|
294
|
+
arguments.
|
|
271
295
|
|
|
272
296
|
MEASURED 2026-08-05 on a real 250-row table read with limit=20
|
|
273
|
-
offset=40 (page 3 of 13):
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
WITH page/per_page: slice this result in memory, which is what GitHub
|
|
280
|
-
issue #106 asked for and what tests/test_issue_106.py pins. Valid only
|
|
281
|
-
when the result holds the WHOLE set. Slicing a result that is already
|
|
282
|
-
one page of a larger query is refused: the slice is taken from row 0 of
|
|
283
|
-
what you hold, while the rows themselves start at ``offset``, so the
|
|
284
|
-
answer is silently wrong. That is not hypothetical - it is exactly the
|
|
285
|
-
measured Ruby defect, where slicing offset 40 into a 20-row page
|
|
286
|
-
returned NOTHING and still reported a page number and a total.
|
|
287
|
-
|
|
288
|
-
Python and PHP populate ``count`` from a separate COUNT probe, so
|
|
289
|
-
``total`` here is the TRUE total for the filter and not the capped read
|
|
290
|
-
- which is what makes the envelope honest. Ruby and Node still populate
|
|
291
|
-
it with rows returned; see feature 18.
|
|
297
|
+
offset=40 (page 3 of 13): the envelope shipped the same integer twice
|
|
298
|
+
under two spellings (totalPages and total_pages, count and total, data
|
|
299
|
+
and records) in every API response, so every consumer had to guess
|
|
300
|
+
which spelling was canonical. That, and the silent in-memory slice, are
|
|
301
|
+
what this fixes.
|
|
292
302
|
"""
|
|
293
|
-
if
|
|
294
|
-
raise
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
"
|
|
298
|
-
"
|
|
299
|
-
"
|
|
300
|
-
"
|
|
301
|
-
"
|
|
302
|
-
"
|
|
303
|
+
if args or kwargs:
|
|
304
|
+
raise TypeError(
|
|
305
|
+
"to_paginate() takes no arguments -- it describes the page the "
|
|
306
|
+
"query already returned, derived from the limit and offset that "
|
|
307
|
+
"ran. It used to accept (page, per_page) and slice in memory, but "
|
|
308
|
+
"a DatabaseResult holds no connection, so an argument can only "
|
|
309
|
+
"re-slice the rows already in memory and then report total_pages "
|
|
310
|
+
"for pages it can never reach. To read a different page, FETCH it: "
|
|
311
|
+
"fetch(sql, params, limit=per_page, offset=(page - 1) * per_page), "
|
|
312
|
+
"then call to_paginate() with no arguments. (ADR-0043)"
|
|
303
313
|
)
|
|
304
314
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
total_pages = max(1, -(-self.count // per_page)) if per_page > 0 else 1
|
|
309
|
-
return self._paginate_envelope(self.records, page, per_page, self.offset, total_pages)
|
|
310
|
-
|
|
311
|
-
page = page or 1
|
|
312
|
-
per_page = per_page or (self.limit if self.limit and self.limit > 0 else 20)
|
|
313
|
-
total_pages = max(1, -(-self.count // per_page))
|
|
314
|
-
offset = (page - 1) * per_page
|
|
315
|
-
data = self.records[offset:offset + per_page]
|
|
316
|
-
return self._paginate_envelope(data, page, per_page, offset, total_pages)
|
|
317
|
-
|
|
318
|
-
def _paginate_envelope(self, data, page, per_page, offset, total_pages) -> dict:
|
|
319
|
-
"""The one envelope shape, so both modes cannot drift apart."""
|
|
315
|
+
per_page = self.limit if self.limit and self.limit > 0 else len(self.records)
|
|
316
|
+
page = (self.offset // per_page) + 1 if per_page > 0 else 1
|
|
317
|
+
total_pages = max(1, -(-self.count // per_page)) if per_page > 0 else 1
|
|
320
318
|
return {
|
|
321
|
-
"records":
|
|
322
|
-
"
|
|
323
|
-
"
|
|
324
|
-
"
|
|
325
|
-
"
|
|
326
|
-
"
|
|
327
|
-
"
|
|
328
|
-
"per_page": per_page, # backwards compat
|
|
329
|
-
"totalPages": total_pages, # camelCase standard
|
|
330
|
-
"total_pages": total_pages, # backwards compat
|
|
319
|
+
"records": self.records, # the rows returned, verbatim (never re-sliced)
|
|
320
|
+
"total": self.count, # the true COUNT for the filter, not rows returned
|
|
321
|
+
"page": page, # floor(offset / limit) + 1
|
|
322
|
+
"per_page": per_page, # the query's limit
|
|
323
|
+
"total_pages": total_pages, # ceil(total / per_page)
|
|
324
|
+
"limit": per_page, # the SQL limit actually applied
|
|
325
|
+
"offset": self.offset, # the SQL offset actually applied
|
|
331
326
|
}
|
|
332
327
|
|
|
333
328
|
def column_info(self) -> list[dict]:
|