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.
Files changed (245) hide show
  1. {queryapigate-0.47.0/queryapigate.egg-info → queryapigate-0.49.0}/PKG-INFO +1 -1
  2. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/__init__.py +1 -1
  3. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/alerts.py +4 -4
  4. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/app.py +20 -2
  5. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/cli.py +3 -3
  6. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/dataplane.py +33 -3
  7. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/experimental.py +2 -8
  8. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/governance.py +15 -2
  9. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/limits.py +3 -2
  10. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/masking.py +4 -1
  11. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/mcp_server.py +3 -1
  12. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/metrics.py +2 -2
  13. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/ratelimit.py +9 -0
  14. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/runstore.py +1 -1
  15. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/sqltools.py +6 -2
  16. {queryapigate-0.47.0 → queryapigate-0.49.0/queryapigate.egg-info}/PKG-INFO +1 -1
  17. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_cli.py +13 -0
  18. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_dataplane.py +129 -4
  19. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_db.py +4 -1
  20. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_events_server.py +1 -0
  21. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_experimental.py +2 -1
  22. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_history.py +1 -0
  23. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_integration.py +11 -0
  24. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_masking.py +11 -0
  25. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mcp.py +13 -2
  26. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_queryapigate.py +11 -0
  27. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_quotas.py +12 -0
  28. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_rolling_upgrade.py +19 -2
  29. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_runstore.py +8 -3
  30. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_shared_rate_limit.py +13 -0
  31. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_supply_chain.py +38 -2
  32. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_upgrades.py +98 -11
  33. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspace_isolation.py +24 -0
  34. {queryapigate-0.47.0 → queryapigate-0.49.0}/LICENSE +0 -0
  35. {queryapigate-0.47.0 → queryapigate-0.49.0}/NOTICE +0 -0
  36. {queryapigate-0.47.0 → queryapigate-0.49.0}/README.md +0 -0
  37. {queryapigate-0.47.0 → queryapigate-0.49.0}/pyproject.toml +0 -0
  38. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/__main__.py +0 -0
  39. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/accesslog.py +0 -0
  40. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/adminroles.py +0 -0
  41. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/admins.py +0 -0
  42. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/agentwrites.py +0 -0
  43. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/apikeys.py +0 -0
  44. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/auditchain.py +0 -0
  45. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/auditstream.py +0 -0
  46. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/aws.py +0 -0
  47. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/broadcast.py +0 -0
  48. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/bundle.py +0 -0
  49. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/cache.py +0 -0
  50. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/collection_admin.py +0 -0
  51. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/config.py +0 -0
  52. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/configcode.py +0 -0
  53. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console.py +0 -0
  54. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AccessMapPage-CvmP9Sfw.js +0 -0
  55. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AdministratorsPage-B1G7Dgxv.js +0 -0
  56. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AgentsPage-ex_lhxAd.js +0 -0
  57. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AlertsPage-CkFfLPBu.js +0 -0
  58. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ApiKeyDetailPage-DluS3aag.js +0 -0
  59. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ApiKeysPage-D3xeaQlS.js +0 -0
  60. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ApprovalsPage-BYL5hXGT.js +0 -0
  61. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/AuditLogPage-TAMz5LIn.js +0 -0
  62. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/CachingPage-8OhOUgxW.js +0 -0
  63. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/CodeBox-Cwh5KHLp.js +0 -0
  64. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ConnectionsPage-mHkn5I8c.js +0 -0
  65. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/DesignerPage-CPZneqCQ.js +0 -0
  66. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/DestinationsPage-Dy6NYnv3.js +0 -0
  67. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/Experimental-B3dnaeV7.js +0 -0
  68. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/ExportsPage-BzDOR2fh.js +0 -0
  69. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/HelpPage-BV_YH8GV.js +0 -0
  70. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/HomePage-BShpQIil.js +0 -0
  71. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/KeyActivity-DuTyhSGT.js +0 -0
  72. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/Managed-CQ0VXNNZ.js +0 -0
  73. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/McpPage-JMpMLJIz.js +0 -0
  74. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/MetricsPage-BOUZqgao.js +0 -0
  75. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/QueryInfo-n4U1MkL1.js +0 -0
  76. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/RepositoryPage-Cj_ZwMQj.js +0 -0
  77. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/Results-6QW3JK2B.js +0 -0
  78. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/RolesPage-Dq83u-am.js +0 -0
  79. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/SchemaBrowser-BzZ6DZjO.js +0 -0
  80. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/SettingsPage-CpjtKQ1P.js +0 -0
  81. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/StatTile-Cft4kJPH.js +0 -0
  82. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/Time-DurHvFXj.js +0 -0
  83. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/WorkspacesPage-Bfe_wYYe.js +0 -0
  84. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/api-CgyYL3VS.js +0 -0
  85. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/client-ZHNiMG8H.js +0 -0
  86. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/forms-DR_JyNks.js +0 -0
  87. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/forms-DdFqq646.js +0 -0
  88. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/icons-CFXE8hAz.js +0 -0
  89. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/index-0v-rImZs.js +0 -0
  90. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/index-Cv64XKNs.css +0 -0
  91. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/maskRules-CvKQIPgN.js +0 -0
  92. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/reach-BAOLd75Y.js +0 -0
  93. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/shared-D8E1i8vU.js +0 -0
  94. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
  95. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/assets/useMutation-D4M562eQ.js +0 -0
  96. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/favicon.svg +0 -0
  97. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/console_dist/index.html +0 -0
  98. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/cors.py +0 -0
  99. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/databases.py +0 -0
  100. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/db.py +0 -0
  101. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/definitions.py +0 -0
  102. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/deprecations.py +0 -0
  103. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/descriptions.py +0 -0
  104. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/destinations.py +0 -0
  105. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/duckfiles.py +0 -0
  106. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/embeddings.py +0 -0
  107. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/engine.py +0 -0
  108. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/errors.py +0 -0
  109. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/events.py +0 -0
  110. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/examples.py +0 -0
  111. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/exporting.py +0 -0
  112. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/exports.py +0 -0
  113. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/formats.py +0 -0
  114. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/gunicorn_conf.py +0 -0
  115. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/history.py +0 -0
  116. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/instances.py +0 -0
  117. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/jwtauth.py +0 -0
  118. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
  119. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/logging_setup.py +0 -0
  120. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/metacache.py +0 -0
  121. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/mongodocs.py +0 -0
  122. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/mongotools.py +0 -0
  123. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/oidc.py +0 -0
  124. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/openapi.py +0 -0
  125. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/params.py +0 -0
  126. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/parquet.py +0 -0
  127. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/pool.py +0 -0
  128. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/postman.py +0 -0
  129. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/quotas.py +0 -0
  130. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/rediscache.py +0 -0
  131. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/rekey.py +0 -0
  132. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/runners.py +0 -0
  133. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/schema.py +0 -0
  134. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/scope.py +0 -0
  135. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/secheaders.py +0 -0
  136. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/secretrefs.py +0 -0
  137. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/__init__.py +0 -0
  138. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/access.py +0 -0
  139. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/administrators.py +0 -0
  140. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/audit.py +0 -0
  141. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/collections.py +0 -0
  142. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/connections.py +0 -0
  143. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/descriptions.py +0 -0
  144. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/destinations.py +0 -0
  145. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/exports.py +0 -0
  146. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/mcp.py +0 -0
  147. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/queries.py +0 -0
  148. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/services/workspaces.py +0 -0
  149. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/sqlflow.py +0 -0
  150. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/sso.py +0 -0
  151. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/store.py +0 -0
  152. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/tableguard.py +0 -0
  153. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/tracing.py +0 -0
  154. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/usage.py +0 -0
  155. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/v1.py +0 -0
  156. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/v1_spec.py +0 -0
  157. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/vault.py +0 -0
  158. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate/workspaces.py +0 -0
  159. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/SOURCES.txt +0 -0
  160. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/dependency_links.txt +0 -0
  161. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/entry_points.txt +0 -0
  162. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/requires.txt +0 -0
  163. {queryapigate-0.47.0 → queryapigate-0.49.0}/queryapigate.egg-info/top_level.txt +0 -0
  164. {queryapigate-0.47.0 → queryapigate-0.49.0}/setup.cfg +0 -0
  165. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_adhoc_history.py +0 -0
  166. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_adminroles.py +0 -0
  167. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_admins.py +0 -0
  168. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_agent_writes.py +0 -0
  169. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_alerts.py +0 -0
  170. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_api_administrators.py +0 -0
  171. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_api_keys.py +0 -0
  172. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_api_v1.py +0 -0
  173. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_audit_chain.py +0 -0
  174. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_audit_log.py +0 -0
  175. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_audit_siem.py +0 -0
  176. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_audit_stream.py +0 -0
  177. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_aws.py +0 -0
  178. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_backup_restore.py +0 -0
  179. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_broadcast.py +0 -0
  180. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_collection_bundle.py +0 -0
  181. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_collection_postman.py +0 -0
  182. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_collections.py +0 -0
  183. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_configcode.py +0 -0
  184. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_connection_encryption.py +0 -0
  185. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_console.py +0 -0
  186. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_cors_and_rate_limit.py +0 -0
  187. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_data_protection.py +0 -0
  188. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_databases.py +0 -0
  189. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_dependency_floors.py +0 -0
  190. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_deprecations.py +0 -0
  191. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_descriptions.py +0 -0
  192. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_destinations.py +0 -0
  193. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_docs_site.py +0 -0
  194. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_duckdb.py +0 -0
  195. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_duckdb_files.py +0 -0
  196. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_embeddings.py +0 -0
  197. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_errors.py +0 -0
  198. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_events.py +0 -0
  199. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_examples.py +0 -0
  200. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_export_security.py +0 -0
  201. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_exporting.py +0 -0
  202. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_exports.py +0 -0
  203. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_fips.py +0 -0
  204. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_helm.py +0 -0
  205. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_howto.py +0 -0
  206. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_instances.py +0 -0
  207. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_jdbc.py +0 -0
  208. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_jwt.py +0 -0
  209. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_key_rotation.py +0 -0
  210. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_limits.py +0 -0
  211. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_masking_requests.py +0 -0
  212. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mcp_keycloak.py +0 -0
  213. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mcp_oauth.py +0 -0
  214. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_metacache.py +0 -0
  215. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mongo_streaming.py +0 -0
  216. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_mongotools.py +0 -0
  217. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_multinode.py +0 -0
  218. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_observability.py +0 -0
  219. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_oidc.py +0 -0
  220. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_params.py +0 -0
  221. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_parquet.py +0 -0
  222. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_pool.py +0 -0
  223. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_publishing.py +0 -0
  224. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_query_window.py +0 -0
  225. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_rekey.py +0 -0
  226. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_release_check.py +0 -0
  227. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_response_cache.py +0 -0
  228. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_roles.py +0 -0
  229. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_runstore_differential.py +0 -0
  230. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_secretrefs.py +0 -0
  231. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_security_headers.py +0 -0
  232. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_settings.py +0 -0
  233. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sql_guard_fuzz.py +0 -0
  234. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sql_guard_writes.py +0 -0
  235. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sqlflow.py +0 -0
  236. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sso.py +0 -0
  237. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_sso_keycloak.py +0 -0
  238. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_tableguard.py +0 -0
  239. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_tracing.py +0 -0
  240. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_usage.py +0 -0
  241. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_vault.py +0 -0
  242. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspace_config_cli.py +0 -0
  243. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspace_observability.py +0 -0
  244. {queryapigate-0.47.0 → queryapigate-0.49.0}/tests/test_workspace_store.py +0 -0
  245. {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.47.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
@@ -1,5 +1,5 @@
1
1
  """QueryAPIGate - expose SQL databases as a REST API."""
2
- __version__ = "0.47.0"
2
+ __version__ = "0.49.0"
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -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
- """One rate-limit refusal, for the 'keeps hitting its limit' alert: kind 'key' (a key's own rate_limit) or
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.append(now)
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()). Its headers are
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, experimental): "
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, with none of the limits that keep their own state (rate limit, quota, ADR 0011's)."""
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 and permission.quota is None and permission.max_concurrent is None
87
- and permission.query_timeout is None and permission.max_rows is None)
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.name, count, period)
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
- if not store.acquire(permission.name, maximum, token, lease):
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(permission.name, token, lease, store)
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 = value.decode('utf-8', 'replace') if isinstance(value, (bytes, bytearray)) else str(value)
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
- structured = {'rows': response.get_json(), 'truncated': response.headers.get('X-Has-More') == 'true'}
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 += 1
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, experimental): run history, the event log and usage rollups in ClickHouse.
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.47.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 test_never_for_a_key_with_a_rate_limit(self):
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
- python = re.search(r'^FROM python:[\d.]+-slim@sha256:\w+$', dockerfile, re.M)
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(python)
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), 'trixie', 'the Debian release python:3.12-slim is on')
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)