queryapigate 0.47.0__tar.gz → 0.49.0__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.
- {queryapigate-0.47.0/queryapigate.egg-info → queryapigate-0.49.0}/PKG-INFO +1 -1
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/__init__.py +1 -1
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/alerts.py +4 -4
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/app.py +20 -2
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/cli.py +3 -3
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/dataplane.py +33 -3
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/experimental.py +2 -8
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/governance.py +15 -2
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/limits.py +3 -2
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/masking.py +4 -1
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/mcp_server.py +3 -1
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/metrics.py +2 -2
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/ratelimit.py +9 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/runstore.py +1 -1
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/sqltools.py +6 -2
- {queryapigate-0.47.0 → queryapigate-0.49.0/queryapigate.egg-info}/PKG-INFO +1 -1
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_cli.py +13 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_dataplane.py +129 -4
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_db.py +4 -1
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_events_server.py +1 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_experimental.py +2 -1
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_history.py +1 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_integration.py +11 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_masking.py +11 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mcp.py +13 -2
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_queryapigate.py +11 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_quotas.py +12 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_rolling_upgrade.py +19 -2
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_runstore.py +8 -3
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_shared_rate_limit.py +13 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_supply_chain.py +38 -2
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_upgrades.py +98 -11
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspace_isolation.py +24 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/LICENSE +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/NOTICE +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/README.md +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/pyproject.toml +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/__main__.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/accesslog.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/adminroles.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/admins.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/agentwrites.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/apikeys.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/auditchain.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/auditstream.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/aws.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/broadcast.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/bundle.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/cache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/collection_admin.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/config.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/configcode.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AccessMapPage-CvmP9Sfw.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AdministratorsPage-B1G7Dgxv.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AgentsPage-ex_lhxAd.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AlertsPage-CkFfLPBu.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ApiKeyDetailPage-DluS3aag.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ApiKeysPage-D3xeaQlS.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ApprovalsPage-BYL5hXGT.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AuditLogPage-TAMz5LIn.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/CachingPage-8OhOUgxW.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/CodeBox-Cwh5KHLp.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ConnectionsPage-mHkn5I8c.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/DesignerPage-CPZneqCQ.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/DestinationsPage-Dy6NYnv3.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/Experimental-B3dnaeV7.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ExportsPage-BzDOR2fh.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/HelpPage-BV_YH8GV.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/HomePage-BShpQIil.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/KeyActivity-DuTyhSGT.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/Managed-CQ0VXNNZ.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/McpPage-JMpMLJIz.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/MetricsPage-BOUZqgao.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/QueryInfo-n4U1MkL1.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/RepositoryPage-Cj_ZwMQj.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/Results-6QW3JK2B.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/RolesPage-Dq83u-am.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/SchemaBrowser-BzZ6DZjO.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/SettingsPage-CpjtKQ1P.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/StatTile-Cft4kJPH.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/Time-DurHvFXj.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/WorkspacesPage-Bfe_wYYe.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/api-CgyYL3VS.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/client-ZHNiMG8H.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/forms-DR_JyNks.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/forms-DdFqq646.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/icons-CFXE8hAz.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/index-0v-rImZs.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/index-Cv64XKNs.css +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/maskRules-CvKQIPgN.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/reach-BAOLd75Y.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/shared-D8E1i8vU.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/useMutation-D4M562eQ.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/favicon.svg +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/index.html +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/cors.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/databases.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/db.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/definitions.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/deprecations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/descriptions.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/destinations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/duckfiles.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/embeddings.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/engine.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/errors.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/events.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/examples.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/exporting.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/exports.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/formats.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/gunicorn_conf.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/history.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/instances.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/jwtauth.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/logging_setup.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/metacache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/mongodocs.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/mongotools.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/oidc.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/openapi.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/params.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/parquet.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/pool.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/postman.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/quotas.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/rediscache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/rekey.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/runners.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/schema.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/scope.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/secheaders.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/secretrefs.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/__init__.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/access.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/administrators.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/audit.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/collections.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/connections.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/descriptions.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/destinations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/exports.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/mcp.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/queries.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/workspaces.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/sqlflow.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/sso.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/store.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/tableguard.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/tracing.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/usage.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/v1.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/v1_spec.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/vault.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/workspaces.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/SOURCES.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/dependency_links.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/entry_points.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/requires.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/top_level.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/setup.cfg +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_adhoc_history.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_adminroles.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_admins.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_agent_writes.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_alerts.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_api_administrators.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_api_keys.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_api_v1.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_audit_chain.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_audit_log.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_audit_siem.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_audit_stream.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_aws.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_backup_restore.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_broadcast.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_collection_bundle.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_collection_postman.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_collections.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_configcode.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_connection_encryption.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_console.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_cors_and_rate_limit.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_data_protection.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_databases.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_dependency_floors.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_deprecations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_descriptions.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_destinations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_docs_site.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_duckdb.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_duckdb_files.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_embeddings.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_errors.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_events.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_examples.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_export_security.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_exporting.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_exports.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_fips.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_helm.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_howto.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_instances.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_jdbc.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_jwt.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_key_rotation.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_limits.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_masking_requests.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mcp_keycloak.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mcp_oauth.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_metacache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mongo_streaming.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mongotools.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_multinode.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_observability.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_oidc.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_params.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_parquet.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_pool.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_publishing.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_query_window.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_rekey.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_release_check.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_response_cache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_roles.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_runstore_differential.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_secretrefs.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_security_headers.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_settings.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sql_guard_fuzz.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sql_guard_writes.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sqlflow.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sso.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sso_keycloak.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_tableguard.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_tracing.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_usage.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_vault.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspace_config_cli.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspace_observability.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspace_store.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspaces_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.49.0
|
|
4
4
|
Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -44,13 +44,13 @@ _lock = threading.Lock()
|
|
|
44
44
|
_refusals: dict[tuple, deque] = {} # ('key' | 'client', who) -> monotonic times of rate-limit refusals
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def note_rate_limited(kind, who):
|
|
48
|
-
"""
|
|
49
|
-
'client' (QUERYAPIGATE_RATE_LIMIT, by address - checked before the caller is known)."""
|
|
47
|
+
def note_rate_limited(kind, who, count=1):
|
|
48
|
+
"""A rate-limit refusal (`count` of them, now), for the 'keeps hitting its limit' alert: kind 'key' (a key's own
|
|
49
|
+
rate_limit) or 'client' (QUERYAPIGATE_RATE_LIMIT, by address - checked before the caller is known)."""
|
|
50
50
|
now = time.monotonic()
|
|
51
51
|
with _lock:
|
|
52
52
|
times = _refusals.setdefault((kind, who or 'unknown'), deque(maxlen=1000))
|
|
53
|
-
times.
|
|
53
|
+
times.extend([now] * min(count, 1000))
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
# Every kind of alert, the one list: the OpenAPI enum is built from it and tests/test_alerts.py checks API.md's table.
|
|
@@ -178,7 +178,8 @@ class _CapturePeer:
|
|
|
178
178
|
secret names the client's own socket address in X-QAG-Peer, and that address is taken as the real one - so this
|
|
179
179
|
server sees the request as if the client had connected directly, ProxyFix and QUERYAPIGATE_TRUST_PROXY included.
|
|
180
180
|
A peer that isn't an IP address counts as remote, as a Unix socket does. A data plane from another release
|
|
181
|
-
(X-QAG-Dataplane-Version) is still the data plane, but is given no plan (dataplane.paired()).
|
|
181
|
+
(X-QAG-Dataplane-Version) is still the data plane, but is given no plan (dataplane.paired()). The rate-limit buckets
|
|
182
|
+
it counted a call in before forwarding it (X-QAG-Rate-Counted, ADR 0032) are taken from it alone. Its headers are
|
|
182
183
|
removed here, so nothing after this layer reads, logs or passes on the secret."""
|
|
183
184
|
|
|
184
185
|
def __init__(self, app):
|
|
@@ -189,10 +190,13 @@ class _CapturePeer:
|
|
|
189
190
|
peer = environ.pop(dataplane.PEER_ENVIRON, None)
|
|
190
191
|
wanted = environ.pop(dataplane.WANTED_ENVIRON, None)
|
|
191
192
|
version = environ.pop(dataplane.VERSION_ENVIRON, None)
|
|
193
|
+
rate_counted = environ.pop(dataplane.COUNTED_ENVIRON, None)
|
|
192
194
|
if supplied is not None and dataplane.matches(supplied):
|
|
193
195
|
is_paired = dataplane.paired(version)
|
|
194
196
|
environ['queryapigate.dataplane'] = 'plan' if wanted == '1' and is_paired else True
|
|
195
197
|
environ['queryapigate.dataplane_paired'] = is_paired
|
|
198
|
+
if rate_counted is not None and is_paired:
|
|
199
|
+
environ['queryapigate.rate_counted'] = dataplane.counted(rate_counted)
|
|
196
200
|
environ['REMOTE_ADDR'] = peer if dataplane.is_address(peer) else ''
|
|
197
201
|
environ['queryapigate.peer_addr'] = environ.get('REMOTE_ADDR')
|
|
198
202
|
return self.app(environ, start_response)
|
|
@@ -475,6 +479,10 @@ def check_rate_limit():
|
|
|
475
479
|
"""Count this request against its client's quota (governance.py); returns a 429 response when it is over."""
|
|
476
480
|
if request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
|
|
477
481
|
return None
|
|
482
|
+
counted = request.environ.get('queryapigate.rate_counted', {}).get('client')
|
|
483
|
+
if counted is not None and config.rate_limit() is not None: # by the data plane, which then forwarded the call
|
|
484
|
+
g.rate_limit = counted
|
|
485
|
+
return None
|
|
478
486
|
verdict = governance.check_client_limit(current_app, request.remote_addr)
|
|
479
487
|
if verdict is None:
|
|
480
488
|
return None
|
|
@@ -488,6 +496,10 @@ def check_key_rate_limit():
|
|
|
488
496
|
per-key identity to limit by before then; a no-op for the admin key and the open/no-key case."""
|
|
489
497
|
if request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
|
|
490
498
|
return None
|
|
499
|
+
counted = request.environ.get('queryapigate.rate_counted', {}).get('key')
|
|
500
|
+
if counted is not None and getattr(g.get('permission'), 'rate_limit', None) is not None: # as the client's, above
|
|
501
|
+
g.key_rate_limit = counted
|
|
502
|
+
return None
|
|
491
503
|
verdict = governance.check_key_limit(current_app, g.get('permission'))
|
|
492
504
|
if verdict is None:
|
|
493
505
|
return None
|
|
@@ -1236,9 +1248,15 @@ def dataplane_runs():
|
|
|
1236
1248
|
|
|
1237
1249
|
Each run comes with its history entry already JSON (`entry`), and the labels and timings /metrics counts it by: at
|
|
1238
1250
|
the data plane's rates Python's own work per run - encoding it, and its labels - is what it can't keep up with, so
|
|
1239
|
-
a batch is queued at once (history.record_reported()) and counted once per set of labels.
|
|
1251
|
+
a batch is queued at once (history.record_reported()) and counted once per set of labels.
|
|
1252
|
+
|
|
1253
|
+
`rate_limited`: the calls it refused itself with a 429 (ADR 0032) - by bucket, caller and method, one duration
|
|
1254
|
+
each - counted as gate()'s own refusals are: in the rejections, the alerts and the request counters. Not runs."""
|
|
1240
1255
|
report = request.get_json(silent=True) or {}
|
|
1241
1256
|
runs = report.get('runs') or []
|
|
1257
|
+
for refused in report.get('rate_limited') or []:
|
|
1258
|
+
governance.note_refused(refused['scope'], refused.get('client'), refused.get('workspace'), refused.get('key'),
|
|
1259
|
+
refused['method'], refused['seconds'])
|
|
1242
1260
|
broadcaster = current_app.extensions['queryapigate_broadcaster']
|
|
1243
1261
|
with scope.using(scope.current()): # resolved once, not by each of a busy batch's few thousand label lookups
|
|
1244
1262
|
if report.get('recorded'): # in the run store already: written by the data plane itself (ADR 0029 §4)
|
|
@@ -127,8 +127,6 @@ def _init(args):
|
|
|
127
127
|
inactive) now that connections are SQLite-backed - no more db_connections.json/saved_sql/ for a new
|
|
128
128
|
install. On a home whose store already has connections, this is a no-op - "already exists - left
|
|
129
129
|
untouched"."""
|
|
130
|
-
home = config.home()
|
|
131
|
-
home.mkdir(parents=True, exist_ok=True)
|
|
132
130
|
if db.connection().execute('SELECT 1 FROM connections LIMIT 1').fetchone() is not None:
|
|
133
131
|
print(f'{db.describe()} already has connections - left untouched')
|
|
134
132
|
return 0
|
|
@@ -903,7 +901,7 @@ def build_parser():
|
|
|
903
901
|
collection_import.add_argument('--dry-run', action='store_true', help='report what would happen; write nothing')
|
|
904
902
|
collection_import.set_defaults(func=_collection_import)
|
|
905
903
|
|
|
906
|
-
config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014
|
|
904
|
+
config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014): "
|
|
907
905
|
'export it, see what applying one would change, apply it')
|
|
908
906
|
config_parser.set_defaults(func=lambda a: config_parser.print_help() or 2)
|
|
909
907
|
config_actions = config_parser.add_subparsers(dest='action')
|
|
@@ -1041,6 +1039,8 @@ def main(argv=None):
|
|
|
1041
1039
|
os.environ['QUERYAPIGATE_HOME'] = args.home
|
|
1042
1040
|
if getattr(args, 'server', None) or getattr(args, 'func', None) is _config_new_key:
|
|
1043
1041
|
return args.func(args) # nothing of this home's: no store opened, so none made in a CI checkout
|
|
1042
|
+
if getattr(args, 'func', None) is _init: # the one command that makes a home: the store below opens inside it
|
|
1043
|
+
config.home().mkdir(parents=True, exist_ok=True)
|
|
1044
1044
|
try:
|
|
1045
1045
|
config.check_database_url()
|
|
1046
1046
|
db.init_schema()
|
|
@@ -21,6 +21,11 @@ which is slower but answers as Python does.
|
|
|
21
21
|
|
|
22
22
|
Saved queries with `{name}` text placeholders never get a plan: their values are part of the SQL text, so the verdict
|
|
23
23
|
can change from one call to the next.
|
|
24
|
+
|
|
25
|
+
With Redis (QUERYAPIGATE_REDIS_URL), a key's rate limit and the server-wide QUERYAPIGATE_RATE_LIMIT are counted by the
|
|
26
|
+
data plane too, in the same buckets (ADR 0032): the plan carries the key's limit, and the data plane counts a call
|
|
27
|
+
only once it is going to serve it. A call it forwards after counting says so (X-QAG-Rate-Counted), and is not counted
|
|
28
|
+
again here. Its refusals reach /metrics and the alerts with its runs report.
|
|
24
29
|
"""
|
|
25
30
|
import base64
|
|
26
31
|
import hmac
|
|
@@ -37,6 +42,7 @@ PEER_HEADER = 'X-QAG-Peer'
|
|
|
37
42
|
WANTED_HEADER = 'X-QAG-Plan-Wanted'
|
|
38
43
|
VERSION_HEADER = 'X-QAG-Dataplane-Version' # the data plane's release, sent with its secret
|
|
39
44
|
PLAN_HEADER = 'X-QAG-Plan'
|
|
45
|
+
COUNTED_HEADER = 'X-QAG-Rate-Counted' # the rate-limit buckets a call the data plane forwards was counted in already
|
|
40
46
|
VERSION = 1
|
|
41
47
|
DIALECTS = ('postgres', 'mysql') # the two the prototype serves - every other connection is forwarded
|
|
42
48
|
_ROWS = ('api_key', 'saved_query', 'connection') # what a plan names the digests of, one row set each
|
|
@@ -47,6 +53,7 @@ PEER_ENVIRON = 'HTTP_X_QAG_PEER'
|
|
|
47
53
|
# The data plane asks for a plan only when it would keep one: other calls it forwards keep the metadata cache
|
|
48
54
|
WANTED_ENVIRON = 'HTTP_X_QAG_PLAN_WANTED'
|
|
49
55
|
VERSION_ENVIRON = 'HTTP_X_QAG_DATAPLANE_VERSION'
|
|
56
|
+
COUNTED_ENVIRON = 'HTTP_X_QAG_RATE_COUNTED'
|
|
50
57
|
UNPAIRED = ('This data plane is from another release than this server: it gets no plans and no run store lease '
|
|
51
58
|
'until both are the same release')
|
|
52
59
|
_unpaired_seen: set = set()
|
|
@@ -80,11 +87,29 @@ def is_address(peer):
|
|
|
80
87
|
|
|
81
88
|
def eligible_caller(permission):
|
|
82
89
|
"""A caller the data plane may serve: a scoped API key - not an administrator, a signed-in user or an open server -
|
|
83
|
-
in the default workspace,
|
|
90
|
+
in the default workspace, without the limits that keep state: no quota or max_concurrent, and a rate limit only
|
|
91
|
+
when Redis holds its bucket, where the data plane counts it too (ADR 0032). Without Redis each process would count
|
|
92
|
+
its own budget. A query_timeout and max_rows keep none: the plan carries them, and the data plane applies each as
|
|
93
|
+
limits.py does."""
|
|
84
94
|
return (permission is not None and not permission.admin and permission.claims is None and permission.role is None
|
|
85
95
|
and not permission.claim_binding and permission.workspace == 'default'
|
|
86
|
-
and permission.rate_limit is None
|
|
87
|
-
and permission.
|
|
96
|
+
and (permission.rate_limit is None or config.redis_url() is not None)
|
|
97
|
+
and permission.quota is None and permission.max_concurrent is None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def counted(value):
|
|
101
|
+
"""X-QAG-Rate-Counted (`client=<limit>,<remaining>; key=<limit>,<remaining>`, either or both) as
|
|
102
|
+
{'client' | 'key': (limit, remaining)}: the buckets the data plane counted this call in before forwarding it, and
|
|
103
|
+
what they said. {} for a value of any other shape."""
|
|
104
|
+
found = {}
|
|
105
|
+
for part in (value or '').split(';'):
|
|
106
|
+
bucket, _, numbers = part.strip().partition('=')
|
|
107
|
+
limit, _, remaining = numbers.partition(',')
|
|
108
|
+
if bucket not in ('client', 'key') or bucket in found or not (limit.isascii() and limit.isdigit()
|
|
109
|
+
and remaining.isascii() and remaining.isdigit()):
|
|
110
|
+
return {}
|
|
111
|
+
found[bucket] = (int(limit), int(remaining))
|
|
112
|
+
return found
|
|
88
113
|
|
|
89
114
|
|
|
90
115
|
def digests(record):
|
|
@@ -161,6 +186,11 @@ def build(record, permission, query_name, number, saved, connection_name, result
|
|
|
161
186
|
return None
|
|
162
187
|
mask = {'columns': len(masked.traced), 'actions': masked.actions, 'fired': masked.fired}
|
|
163
188
|
return {'v': VERSION, 'rows': rows, 'key': {'workspace': permission.workspace, 'name': permission.name},
|
|
189
|
+
# (count, period in seconds): counted by the data plane in Redis, in the bucket Python counts it in
|
|
190
|
+
'rate_limit': list(permission.rate_limit) if permission.rate_limit else None,
|
|
191
|
+
# the key's own limits (ADR 0011), applied as limits.timeout() and limits.rows() apply them; an int
|
|
192
|
+
# query_timeout stays one, as min() keeps it
|
|
193
|
+
'query_timeout': permission.query_timeout, 'max_rows': permission.max_rows,
|
|
164
194
|
'query': {'name': query_name, 'version': number}, 'connection': {'name': connection_name,
|
|
165
195
|
'dialect': dialect}, 'statement': sent, 'search_path': prepared['search_path'], 'mask': mask,
|
|
166
196
|
'expires_in': expires_in,
|
|
@@ -12,7 +12,8 @@ added - when it meets every one of GRADUATION. Alerts, remote files through Duck
|
|
|
12
12
|
Console sign-in through SSO in 0.30 (ADR 0017), with the Helm chart; the audit log streamed to a SIEM in 0.32, once
|
|
13
13
|
tested against real receivers (ADR 0019); live events, and secrets from files and Vault, in 0.34 (ADR 0021) - AWS's
|
|
14
14
|
stay, until tested against AWS. The connection types have their own terms (databases.GRADUATION): MongoDB met them
|
|
15
|
-
in 0.37, with streaming exports (ADR 0022).
|
|
15
|
+
in 0.37, with streaming exports (ADR 0022). The ClickHouse run store graduated in 0.49 (ADR 0029), once upgrades from
|
|
16
|
+
each release that had it were tested.
|
|
16
17
|
"""
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
@@ -75,13 +76,6 @@ FEATURES: dict[str, dict[str, Any]] = {
|
|
|
75
76
|
'settings': ('QUERYAPIGATE_DATAPLANE_SECRET',),
|
|
76
77
|
'docs': ('documentation/DEPLOYMENT.md', 'A Rust data plane in front of /q/'),
|
|
77
78
|
},
|
|
78
|
-
'run_store': {
|
|
79
|
-
'name': 'A ClickHouse run store for history, the event log and usage',
|
|
80
|
-
'why': 'a prototype (ADR 0029, decided go): its tables, its event ids and how it is switched on may change '
|
|
81
|
-
'before it graduates',
|
|
82
|
-
'settings': ('QUERYAPIGATE_RUN_STORE_URL',),
|
|
83
|
-
'docs': ('documentation/DEPLOYMENT.md', 'A ClickHouse run store (prototype)'),
|
|
84
|
-
},
|
|
85
79
|
}
|
|
86
80
|
|
|
87
81
|
DB_TYPES = frozenset(t for f in FEATURES.values() for t in f.get('db_types', ()))
|
|
@@ -13,7 +13,7 @@ process - `queryapigate serve`, `queryapigate mcp`, `queryapigate events` - shar
|
|
|
13
13
|
import ipaddress
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
|
|
16
|
-
from . import admins, alerts, apikeys, config, jwtauth, metrics
|
|
16
|
+
from . import admins, alerts, apikeys, config, jwtauth, metrics, ratelimit
|
|
17
17
|
|
|
18
18
|
OPEN_ACCESS_REMOTE = ('This server has no API key set, so it answers only local clients. Set QUERYAPIGATE_API_KEY '
|
|
19
19
|
'to allow access from other hosts.')
|
|
@@ -98,13 +98,26 @@ def check_key_limit(flask_app, permission):
|
|
|
98
98
|
return None
|
|
99
99
|
count, period = permission.rate_limit
|
|
100
100
|
allowed, remaining, retry_after = flask_app.extensions['queryapigate_key_limiter'].hit(
|
|
101
|
-
permission
|
|
101
|
+
ratelimit.budget_name(permission), count, period)
|
|
102
102
|
if not allowed:
|
|
103
103
|
metrics.inc_rate_limit_rejection()
|
|
104
104
|
alerts.note_rate_limited('key', (getattr(permission, 'workspace', 'default'), permission.name))
|
|
105
105
|
return Verdict(allowed, count, remaining, retry_after)
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def note_refused(scope, client_ip, workspace, key, method, elapsed):
|
|
109
|
+
"""429s the data plane answered itself (ADR 0032), one duration each: counted as check_client_limit() and
|
|
110
|
+
check_key_limit() count their own - the rejections and the alerts - and in the request counters, as the calls they
|
|
111
|
+
were. `scope` is 'client' (by `client_ip`, before the caller is known: no key label) or 'key'."""
|
|
112
|
+
if scope == 'client':
|
|
113
|
+
who, label = client_ip, '-'
|
|
114
|
+
else:
|
|
115
|
+
who, label = (workspace or 'default', key), key or '-'
|
|
116
|
+
metrics.inc_rate_limit_rejection(len(elapsed))
|
|
117
|
+
alerts.note_rate_limited(scope, who, len(elapsed))
|
|
118
|
+
metrics.observe_requests(method, 'api.run_named_query', '429', elapsed, label)
|
|
119
|
+
|
|
120
|
+
|
|
108
121
|
def observe(method, endpoint, status, elapsed, key):
|
|
109
122
|
"""One finished call in /metrics' request counters and latency histograms. REST passes the HTTP method and its
|
|
110
123
|
route's endpoint; MCP passes method 'MCP' and an 'mcp.*' endpoint, so the two can be told apart."""
|
|
@@ -229,10 +229,11 @@ def acquire(query_timeout=None):
|
|
|
229
229
|
lease = (query_timeout or LEASE_WITHOUT_TIMEOUT) + LEASE_MARGIN
|
|
230
230
|
token = secrets.token_hex(8)
|
|
231
231
|
store = counter()
|
|
232
|
-
|
|
232
|
+
name = ratelimit.budget_name(permission)
|
|
233
|
+
if not store.acquire(name, maximum, token, lease):
|
|
233
234
|
raise ApiError(f'This caller already has {maximum} queries running, its max_concurrent - retry when one '
|
|
234
235
|
'finishes', 429, code='too_many_concurrent', retry_after=1, max_concurrent=maximum)
|
|
235
|
-
return Slot(
|
|
236
|
+
return Slot(name, token, lease, store)
|
|
236
237
|
|
|
237
238
|
|
|
238
239
|
def effective(grants):
|
|
@@ -404,7 +404,10 @@ def _store_hash_key():
|
|
|
404
404
|
def _hash(value, key):
|
|
405
405
|
if value is None:
|
|
406
406
|
return None
|
|
407
|
-
text
|
|
407
|
+
# Binary values as their text, as JSON shows them - a memoryview too, which psycopg2 hands over for bytea: str() of
|
|
408
|
+
# one is its address, not its value
|
|
409
|
+
text = bytes(value).decode('utf-8', 'replace') if isinstance(value, (bytes, bytearray, memoryview)) \
|
|
410
|
+
else str(value)
|
|
408
411
|
return hmac.new(key, text.encode('utf-8'), hashlib.sha256).hexdigest()[:16]
|
|
409
412
|
|
|
410
413
|
|
|
@@ -344,7 +344,9 @@ def call_tool_for(flask_app, permission, name, arguments):
|
|
|
344
344
|
structured = g.agent_write_result
|
|
345
345
|
return {'content': [{'type': 'text', 'text': json.dumps(structured, default=str)}],
|
|
346
346
|
'structuredContent': structured}
|
|
347
|
-
|
|
347
|
+
# REST answers no rows with {"message": "No results returned"}; a tool's rows are a list (its outputSchema)
|
|
348
|
+
rows = response.get_json() if response.headers.get('X-Row-Count') != '0' else []
|
|
349
|
+
structured = {'rows': rows, 'truncated': response.headers.get('X-Has-More') == 'true'}
|
|
348
350
|
return _rows_result(structured)
|
|
349
351
|
|
|
350
352
|
|
|
@@ -180,10 +180,10 @@ def dec_active_query():
|
|
|
180
180
|
_active_queries -= 1
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def inc_rate_limit_rejection():
|
|
183
|
+
def inc_rate_limit_rejection(count=1):
|
|
184
184
|
global _rate_limit_rejections
|
|
185
185
|
with _lock:
|
|
186
|
-
_rate_limit_rejections +=
|
|
186
|
+
_rate_limit_rejections += count
|
|
187
187
|
|
|
188
188
|
|
|
189
189
|
def inc_rate_limit_fallback():
|
|
@@ -120,6 +120,15 @@ end
|
|
|
120
120
|
return {allowed, math.floor(tokens), retry}
|
|
121
121
|
"""
|
|
122
122
|
_PREFIX = 'qag:ratelimit:'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def budget_name(permission):
|
|
126
|
+
"""What a caller's own budgets - its rate limit's bucket, its max_concurrent slots (limits.py) - are counted
|
|
127
|
+
under: `<workspace>~<name>`, as quotas count theirs. A name is unique only within its workspace, so two workspaces'
|
|
128
|
+
keys of one name are two callers; no workspace name has a `~`."""
|
|
129
|
+
return f"{getattr(permission, 'workspace', None) or 'default'}~{permission.name}"
|
|
130
|
+
|
|
131
|
+
|
|
123
132
|
_DEGRADED_FOR = 600 # seconds a Redis failure keeps the "limits are per instance right now" alert up
|
|
124
133
|
_degraded_at = None
|
|
125
134
|
_last_warned = 0.0
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The run store (ADR 0029
|
|
1
|
+
"""The run store (ADR 0029): run history, the event log and usage rollups in ClickHouse.
|
|
2
2
|
|
|
3
3
|
With QUERYAPIGATE_RUN_STORE_URL set, history.py's writer thread sends its batches here instead of to the metadata
|
|
4
4
|
store, and everything that reads runs or events - GET /api/v1/history, the Console's runs per version, usage
|
|
@@ -270,7 +270,11 @@ def paginate(sql, limit, offset, params=None, dialect=None):
|
|
|
270
270
|
# Parameters
|
|
271
271
|
# --------------------------------------------------------------------------------------
|
|
272
272
|
|
|
273
|
-
def _check_value(name, value):
|
|
273
|
+
def _check_value(name, value, dialect=None):
|
|
274
|
+
if isinstance(value, str) and dialect == 'postgres' and '\x00' in value:
|
|
275
|
+
# psycopg2 refuses it ("A string literal cannot contain NUL") - the caller's value, not the query, is wrong
|
|
276
|
+
raise ApiError(f"Parameter '{name}' contains a NUL character, which PostgreSQL text can't hold",
|
|
277
|
+
code='param_invalid')
|
|
274
278
|
if value is None or isinstance(value, (bool, str)):
|
|
275
279
|
return
|
|
276
280
|
if isinstance(value, (int, float)) and math.isfinite(value):
|
|
@@ -344,7 +348,7 @@ def bind_parameters(sql, params, style, dialect=None):
|
|
|
344
348
|
if missing:
|
|
345
349
|
raise ApiError(f"No value provided for parameter(s): {', '.join(missing)}", code='param_required')
|
|
346
350
|
for name in set(names):
|
|
347
|
-
_check_value(name, params[name])
|
|
351
|
+
_check_value(name, params[name], dialect)
|
|
348
352
|
|
|
349
353
|
escape = (lambda text: text.replace('%', '%%')) if style in ('format', 'pyformat') else (lambda text: text)
|
|
350
354
|
pieces, position = [], 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.49.0
|
|
4
4
|
Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -199,5 +199,18 @@ class LegacySettingsTests(unittest.TestCase):
|
|
|
199
199
|
self.assertEqual(config.api_key(), 'secret')
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
class InitTests(unittest.TestCase):
|
|
203
|
+
def test_init_makes_a_home_that_doesn_t_exist_yet(self):
|
|
204
|
+
tmp = tempfile.TemporaryDirectory()
|
|
205
|
+
self.addCleanup(tmp.cleanup)
|
|
206
|
+
home = os.path.join(tmp.name, 'new', 'home')
|
|
207
|
+
out = io.StringIO()
|
|
208
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_HOME': home}), redirect_stdout(out):
|
|
209
|
+
self.assertEqual(cli.main(['init']), 0)
|
|
210
|
+
self.assertTrue(store.read_connections()) # the templates, in the store it made
|
|
211
|
+
self.assertTrue(os.path.isdir(home))
|
|
212
|
+
self.assertIn('Created', out.getvalue())
|
|
213
|
+
|
|
214
|
+
|
|
202
215
|
if __name__ == '__main__':
|
|
203
216
|
unittest.main()
|
|
@@ -115,9 +115,38 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
115
115
|
**PAIRED})
|
|
116
116
|
self.assertIsNone(res.headers.get(dataplane.PLAN_HEADER))
|
|
117
117
|
|
|
118
|
-
def
|
|
118
|
+
def test_never_for_a_key_with_a_rate_limit_without_redis(self):
|
|
119
119
|
self.assertIsNone(self.call('/q/by_id?id=1', self.limited)[1])
|
|
120
120
|
|
|
121
|
+
def test_with_redis_a_rate_limited_key_gets_one_that_carries_its_limit(self):
|
|
122
|
+
# Its bucket is in Redis, where the data plane counts it too (ADR 0032): the plan says what the limit is
|
|
123
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_REDIS_URL': 'redis://127.0.0.1:1/0'}):
|
|
124
|
+
plan = self.call('/q/by_id?id=1', self.limited)[1]
|
|
125
|
+
self.assertEqual(plan['rate_limit'], [100, 60])
|
|
126
|
+
self.assertIsNone(self.call('/q/by_id?id=1', self.plain)[1]['rate_limit'])
|
|
127
|
+
|
|
128
|
+
def test_never_for_the_limits_that_keep_their_own_state_with_redis_or_without(self):
|
|
129
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_REDIS_URL': 'redis://127.0.0.1:1/0'}):
|
|
130
|
+
for name, grants in (('quota', {'quota': {'calls': 100, 'per': 'day'}}), ('slots', {'max_concurrent': 2})):
|
|
131
|
+
with self.subTest(name):
|
|
132
|
+
key = apikeys.create_key(name, connections=['pg'], **grants)
|
|
133
|
+
self.assertIsNone(self.call('/q/by_id?id=1', key)[1])
|
|
134
|
+
|
|
135
|
+
def test_a_key_s_own_time_and_row_limits_travel_in_its_plan(self):
|
|
136
|
+
# They keep no state: the data plane applies each as limits.py does. A whole-number timeout stays an int,
|
|
137
|
+
# as Python's min() keeps it (its 504 says 5, not 5.0)
|
|
138
|
+
for name, grants, expected in (('timeout', {'query_timeout': 5}, (5, None)),
|
|
139
|
+
('fraction', {'query_timeout': 0.5}, (0.5, None)),
|
|
140
|
+
('rows', {'max_rows': 10}, (None, 10)),
|
|
141
|
+
('both', {'query_timeout': 2, 'max_rows': 3}, (2, 3))):
|
|
142
|
+
with self.subTest(name):
|
|
143
|
+
key = apikeys.create_key(name, connections=['pg'], **grants)
|
|
144
|
+
plan = self.call('/q/by_id?id=1', key)[1]
|
|
145
|
+
self.assertEqual((plan['query_timeout'], plan['max_rows']), expected)
|
|
146
|
+
self.assertIs(type(plan['query_timeout']), type(expected[0]))
|
|
147
|
+
plain = self.call('/q/by_id?id=1', self.plain)[1]
|
|
148
|
+
self.assertEqual((plain['query_timeout'], plain['max_rows']), (None, None))
|
|
149
|
+
|
|
121
150
|
def test_never_on_a_connection_the_prototype_does_not_serve(self):
|
|
122
151
|
self.assertIsNone(self.call('/q/lite_by_id?id=1', self.plain)[1])
|
|
123
152
|
|
|
@@ -179,11 +208,11 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
179
208
|
dockerfile = f.read()
|
|
180
209
|
rust = re.search(r'^FROM --platform=\$BUILDPLATFORM rust:([\d.]+)-slim-(\w+)@sha256:\w+ AS dataplane$',
|
|
181
210
|
dockerfile, re.M)
|
|
182
|
-
|
|
211
|
+
image = re.search(r'^FROM debian:(\w+)-slim@sha256:\w+$', dockerfile, re.M) # Debian's Python (ADR 0031)
|
|
183
212
|
self.assertIsNotNone(rust, 'the data plane stage: rust:<channel>-slim-<debian>@sha256:..., by digest')
|
|
184
|
-
self.assertIsNotNone(
|
|
213
|
+
self.assertIsNotNone(image, 'the image: debian:<release>-slim@sha256:..., by digest')
|
|
185
214
|
self.assertEqual(rust.group(1), channel)
|
|
186
|
-
self.assertEqual(rust.group(2),
|
|
215
|
+
self.assertEqual(rust.group(2), image.group(1), 'the data plane is built on the Debian release it runs on')
|
|
187
216
|
self.assertNotIn('COPY dataplane/ ', dockerfile)
|
|
188
217
|
self.assertIn('COPY --from=dataplane /queryapigate-dataplane /usr/local/bin/queryapigate-dataplane',
|
|
189
218
|
dockerfile)
|
|
@@ -398,6 +427,57 @@ class ClientAddressTests(unittest.TestCase):
|
|
|
398
427
|
self.assertEqual(self.get('/catalog', peer='192.0.2.1', **{'X-API-Key': key}).status_code, 401)
|
|
399
428
|
|
|
400
429
|
|
|
430
|
+
class CountedByTheDataPlaneTests(PlanTestCase):
|
|
431
|
+
"""A call the data plane forwards after counting it (ADR 0032, section 4): X-QAG-Rate-Counted names the buckets
|
|
432
|
+
and what they said, and Python counts them no more - but only beside the data plane's secret."""
|
|
433
|
+
|
|
434
|
+
def get(self, key, counted, secret=SECRET):
|
|
435
|
+
headers = {'X-API-Key': key, dataplane.COUNTED_HEADER: counted}
|
|
436
|
+
if secret:
|
|
437
|
+
headers.update({dataplane.SECRET_HEADER: secret, **PAIRED})
|
|
438
|
+
return self.client.get('/q/by_id?id=1', headers=headers)
|
|
439
|
+
|
|
440
|
+
def test_the_header_as_the_data_plane_writes_it(self):
|
|
441
|
+
self.assertEqual(dataplane.counted('client=5,3; key=100,42'), {'client': (5, 3), 'key': (100, 42)})
|
|
442
|
+
self.assertEqual(dataplane.counted('key=100,0'), {'key': (100, 0)})
|
|
443
|
+
for odd in ('', 'key=100', 'key=-1,2', 'key=1,2; key=1,2', 'other=1,2', 'key=١,2', 'key=1,2;', None):
|
|
444
|
+
with self.subTest(odd):
|
|
445
|
+
self.assertEqual(dataplane.counted(odd), {})
|
|
446
|
+
|
|
447
|
+
def test_a_key_s_bucket_counted_by_the_data_plane_is_not_counted_again(self):
|
|
448
|
+
res = self.get(self.limited, 'key=100,42')
|
|
449
|
+
self.assertEqual(res.status_code, 200)
|
|
450
|
+
self.assertEqual((res.headers['X-RateLimit-Key-Limit'], res.headers['X-RateLimit-Key-Remaining']),
|
|
451
|
+
('100', '42'))
|
|
452
|
+
res = self.get(self.limited, None, secret=None)
|
|
453
|
+
self.assertEqual(res.headers['X-RateLimit-Key-Remaining'], '99') # Python's own bucket: one call, this one
|
|
454
|
+
|
|
455
|
+
def test_nor_the_client_s(self):
|
|
456
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_RATE_LIMIT': '5/minute'}):
|
|
457
|
+
res = self.get(self.limited, 'client=5,0; key=100,7')
|
|
458
|
+
self.assertEqual(res.status_code, 200)
|
|
459
|
+
names = ('Limit', 'Remaining', 'Key-Limit', 'Key-Remaining')
|
|
460
|
+
self.assertEqual([res.headers[f'X-RateLimit-{name}'] for name in names], ['5', '0', '100', '7'])
|
|
461
|
+
# Only the client's counted there (Redis failed for the key's): Python counts the key's
|
|
462
|
+
res = self.get(self.limited, 'client=5,4')
|
|
463
|
+
self.assertEqual((res.headers['X-RateLimit-Remaining'], res.headers['X-RateLimit-Key-Remaining']),
|
|
464
|
+
('4', '99'))
|
|
465
|
+
|
|
466
|
+
def test_without_the_secret_python_counts_as_ever(self):
|
|
467
|
+
remaining = [self.get(self.limited, 'key=100,42', secret=secret).headers['X-RateLimit-Key-Remaining']
|
|
468
|
+
for secret in (None, 'wrong')]
|
|
469
|
+
self.assertEqual(remaining, ['99', '98'])
|
|
470
|
+
|
|
471
|
+
def test_never_from_a_data_plane_of_another_release(self):
|
|
472
|
+
res = self.client.get('/q/by_id?id=1', headers={'X-API-Key': self.limited, dataplane.SECRET_HEADER: SECRET,
|
|
473
|
+
dataplane.COUNTED_HEADER: 'key=100,50'})
|
|
474
|
+
self.assertEqual(res.headers['X-RateLimit-Key-Remaining'], '99')
|
|
475
|
+
|
|
476
|
+
def test_a_key_without_a_limit_gets_no_limit_headers_from_it(self):
|
|
477
|
+
res = self.get(self.plain, 'key=100,42')
|
|
478
|
+
self.assertNotIn('X-RateLimit-Key-Limit', res.headers)
|
|
479
|
+
|
|
480
|
+
|
|
401
481
|
class RunReportTests(PlanTestCase):
|
|
402
482
|
"""The data plane reports the runs it served (POST /internal/dataplane/runs): each recorded as run_saved() records
|
|
403
483
|
one, and counted in /metrics - and nothing without its secret."""
|
|
@@ -410,6 +490,11 @@ class RunReportTests(PlanTestCase):
|
|
|
410
490
|
'dialect': 'postgres', 'method': 'GET', 'http_status': 200, 'seconds': 0.002, 'query_seconds': 0.001,
|
|
411
491
|
'serialization_seconds': 0.0001}
|
|
412
492
|
|
|
493
|
+
def setUp(self):
|
|
494
|
+
super().setUp()
|
|
495
|
+
from queryapigate import alerts
|
|
496
|
+
self.addCleanup(alerts._refusals.clear) # the refusals reported here are the process's, for its alerts
|
|
497
|
+
|
|
413
498
|
def report(self, secret=SECRET):
|
|
414
499
|
headers = {dataplane.SECRET_HEADER: secret} if secret else {}
|
|
415
500
|
return self.client.post('/internal/dataplane/runs', json={'runs': [self.RUN]}, headers=headers)
|
|
@@ -453,6 +538,46 @@ class RunReportTests(PlanTestCase):
|
|
|
453
538
|
self.assertEqual(metrics.history_counts().get('dropped', 0) - before, 7)
|
|
454
539
|
self.assertEqual(self.history(), [self.ENTRY])
|
|
455
540
|
|
|
541
|
+
def test_the_429s_it_answered_are_counted_as_python_s_own(self):
|
|
542
|
+
# ADR 0032, section 5: in the rejections, the alerts and the request counters - and none is a run
|
|
543
|
+
from queryapigate import alerts
|
|
544
|
+
|
|
545
|
+
def counts():
|
|
546
|
+
text = self.client.get('/metrics', headers=ADMIN).get_data(as_text=True)
|
|
547
|
+
found = []
|
|
548
|
+
for name in ('queryapigate_rate_limit_rejections_total',
|
|
549
|
+
'queryapigate_requests_total{method="GET",endpoint="api.run_named_query",status="429",'
|
|
550
|
+
'key="limited"}',
|
|
551
|
+
'queryapigate_requests_total{method="POST",endpoint="api.run_named_query",status="429",'
|
|
552
|
+
'key="-"}'):
|
|
553
|
+
match = re.search(re.escape(name) + r' (\d+)', text)
|
|
554
|
+
found.append(int(match.group(1)) if match else 0)
|
|
555
|
+
return found
|
|
556
|
+
|
|
557
|
+
def refusals(who):
|
|
558
|
+
with alerts._lock:
|
|
559
|
+
return len(alerts._refusals.get(who, ()))
|
|
560
|
+
before, key, client = counts(), refusals(('key', ('default', 'limited'))), refusals(('client', '203.0.113.9'))
|
|
561
|
+
res = self.client.post('/internal/dataplane/runs', headers={dataplane.SECRET_HEADER: SECRET}, json={
|
|
562
|
+
'runs': [], 'dropped': 0, 'rate_limited': [
|
|
563
|
+
{'scope': 'key', 'workspace': 'default', 'key': 'limited', 'method': 'GET', 'seconds': [0.001, 0.002]},
|
|
564
|
+
{'scope': 'client', 'client': '203.0.113.9', 'method': 'POST', 'seconds': [0.001]}]})
|
|
565
|
+
self.assertEqual(res.status_code, 204)
|
|
566
|
+
self.assertEqual([after - was for after, was in zip(counts(), before, strict=True)], [3, 2, 1])
|
|
567
|
+
self.assertEqual(refusals(('key', ('default', 'limited'))) - key, 2)
|
|
568
|
+
self.assertEqual(refusals(('client', '203.0.113.9')) - client, 1)
|
|
569
|
+
self.assertEqual(self.history(), [])
|
|
570
|
+
|
|
571
|
+
def test_a_key_that_keeps_getting_them_raises_its_alert(self):
|
|
572
|
+
from queryapigate import alerts
|
|
573
|
+
seconds = [0.001] * alerts.RATE_LIMITED_PER_HOUR
|
|
574
|
+
self.client.post('/internal/dataplane/runs', headers={dataplane.SECRET_HEADER: SECRET}, json={
|
|
575
|
+
'runs': [], 'rate_limited': [{'scope': 'key', 'workspace': 'default', 'key': 'limited', 'method': 'GET',
|
|
576
|
+
'seconds': seconds}]})
|
|
577
|
+
raised = [a for a in self.client.get('/api/v1/alerts', headers=ADMIN).get_json()['items']
|
|
578
|
+
if a['kind'] == 'key_rate_limited']
|
|
579
|
+
self.assertEqual([a['id'] for a in raised], ['key_rate_limited:limited'])
|
|
580
|
+
|
|
456
581
|
def test_its_event_is_the_one_python_writes(self):
|
|
457
582
|
from queryapigate import events
|
|
458
583
|
self.assertEqual(self.report().status_code, 204)
|
|
@@ -15,7 +15,7 @@ from unittest import mock
|
|
|
15
15
|
|
|
16
16
|
from queryapigate import config, db, events, history
|
|
17
17
|
from queryapigate.store import now as store_now
|
|
18
|
-
from tests import TEST_DATABASE_URL
|
|
18
|
+
from tests import TEST_DATABASE_URL, TEST_RUN_STORE_URL
|
|
19
19
|
from tests.helpers import save_query, write_connections
|
|
20
20
|
|
|
21
21
|
sqlite_only = unittest.skipIf(TEST_DATABASE_URL, 'inspects the SQLite file itself')
|
|
@@ -316,6 +316,7 @@ class PostgresDbTests(unittest.TestCase):
|
|
|
316
316
|
self.assertIs(db.connection(), parent)
|
|
317
317
|
self.assertEqual(parent.execute('SELECT 1').fetchone()[0], 1) # the parent's is still usable
|
|
318
318
|
|
|
319
|
+
@unittest.skipIf(TEST_RUN_STORE_URL, 'a run store keeps history by time, not by count')
|
|
319
320
|
def test_concurrent_history_writes_are_all_recorded_then_trimmed(self):
|
|
320
321
|
"""Runs recorded from many threads at once (queued, then batched by history.py, whose writes skip the
|
|
321
322
|
store-wide lock on Postgres) must all land, and the per-version cap must still hold afterwards."""
|
|
@@ -456,6 +457,7 @@ class MigrateToPostgresTests(unittest.TestCase):
|
|
|
456
457
|
db.close()
|
|
457
458
|
return snapshot
|
|
458
459
|
|
|
460
|
+
@unittest.skipIf(TEST_RUN_STORE_URL, "the SQLite store's runs are in the run store, which the copy leaves there")
|
|
459
461
|
def test_copies_everything_and_the_api_sees_the_same_store(self):
|
|
460
462
|
before = os.path.getmtime(self.sqlite_path), os.path.getsize(self.sqlite_path)
|
|
461
463
|
copied = db.migrate_sqlite_to_postgres(self.sqlite_path)
|
|
@@ -467,6 +469,7 @@ class MigrateToPostgresTests(unittest.TestCase):
|
|
|
467
469
|
self.assertEqual((os.path.getmtime(self.sqlite_path), os.path.getsize(self.sqlite_path)), before)
|
|
468
470
|
self.assertEqual(copied['events'], 3)
|
|
469
471
|
|
|
472
|
+
@unittest.skipIf(TEST_RUN_STORE_URL, "the SQLite store's events are in the run store, which the copy leaves there")
|
|
470
473
|
def test_event_ids_and_their_sequence_come_across(self):
|
|
471
474
|
"""An id handed out and since swept is not handed out again: the sequence is SQLite's, not MAX(id)."""
|
|
472
475
|
source = sqlite3.connect(self.sqlite_path)
|
|
@@ -247,6 +247,7 @@ class DeliveryTests(EventServerTestCase):
|
|
|
247
247
|
self.assertTrue(stream.nothing_more())
|
|
248
248
|
|
|
249
249
|
@unittest.skipUnless(TEST_DATABASE_URL, 'set QUERYAPIGATE_TEST_DATABASE_URL to run')
|
|
250
|
+
@unittest.skipIf(TEST_RUN_STORE_URL, "the metadata store's NOTIFY: with a run store, the events server tails it")
|
|
250
251
|
def test_postgres_notify_delivers_without_waiting_for_the_poll(self):
|
|
251
252
|
self.start_server(poll_interval=30) # only a NOTIFY can wake this one in time
|
|
252
253
|
stream = self.open('admin')
|
|
@@ -80,7 +80,8 @@ class MarkedEverywhereTests(unittest.TestCase):
|
|
|
80
80
|
('documentation/MCP.md', 'Sign-in for AI clients (OAuth)', '0.30'),
|
|
81
81
|
('documentation/MCP.md', 'Write tools: agents that write, approved by a person',
|
|
82
82
|
'0.30'),
|
|
83
|
-
('documentation/DEPLOYMENT.md', 'Kubernetes (Helm)', '0.30')
|
|
83
|
+
('documentation/DEPLOYMENT.md', 'Kubernetes (Helm)', '0.30'),
|
|
84
|
+
('documentation/DEPLOYMENT.md', 'A ClickHouse run store', '0.49')):
|
|
84
85
|
with self.subTest(heading=heading):
|
|
85
86
|
text = read(path)
|
|
86
87
|
match = re.search(rf'^#+ {re.escape(heading)}\n+(.+)', text, re.M)
|
|
@@ -239,6 +239,7 @@ class RetentionTests(HistoryTestCase):
|
|
|
239
239
|
self.assertEqual(self.stored(), 2)
|
|
240
240
|
|
|
241
241
|
@unittest.skipUnless(TEST_DATABASE_URL, 'several instances only share a PostgreSQL store')
|
|
242
|
+
@unittest.skipIf(TEST_RUN_STORE_URL, "a run store's TTL, not a sweep, keeps its retention")
|
|
242
243
|
def test_while_another_instance_sweeps_this_one_skips(self):
|
|
243
244
|
with mock.patch.dict(os.environ, {'QUERYAPIGATE_HISTORY_RETENTION_DAYS': '7'}):
|
|
244
245
|
now = datetime(2026, 3, 1, 12, 0, 0)
|