gatewaysdk 0.3.2__py3-none-any.whl

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 (376) hide show
  1. gatewaysdk/__init__.py +540 -0
  2. gatewaysdk/adapter/__init__.py +15 -0
  3. gatewaysdk/adapter/base.py +94 -0
  4. gatewaysdk/adapter/messages.py +270 -0
  5. gatewaysdk/adapter/triplet.py +1026 -0
  6. gatewaysdk/algorithm/__init__.py +39 -0
  7. gatewaysdk/algorithm/apo/__init__.py +5 -0
  8. gatewaysdk/algorithm/apo/apo.py +898 -0
  9. gatewaysdk/algorithm/apo/prompts/apply_edit_variant01.poml +22 -0
  10. gatewaysdk/algorithm/apo/prompts/apply_edit_variant02.poml +18 -0
  11. gatewaysdk/algorithm/apo/prompts/text_gradient_variant01.poml +18 -0
  12. gatewaysdk/algorithm/apo/prompts/text_gradient_variant02.poml +16 -0
  13. gatewaysdk/algorithm/apo/prompts/text_gradient_variant03.poml +107 -0
  14. gatewaysdk/algorithm/base.py +262 -0
  15. gatewaysdk/algorithm/decorator.py +264 -0
  16. gatewaysdk/algorithm/evals/__init__.py +7 -0
  17. gatewaysdk/algorithm/evals/evals.py +217 -0
  18. gatewaysdk/algorithm/fast.py +250 -0
  19. gatewaysdk/algorithm/gepa/__init__.py +61 -0
  20. gatewaysdk/algorithm/gepa/adapter.py +495 -0
  21. gatewaysdk/algorithm/gepa/gepa.py +570 -0
  22. gatewaysdk/algorithm/gepa/lib/__init__.py +18 -0
  23. gatewaysdk/algorithm/gepa/lib/adapters/README.md +12 -0
  24. gatewaysdk/algorithm/gepa/lib/adapters/__init__.py +0 -0
  25. gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/README.md +341 -0
  26. gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/__init__.py +1 -0
  27. gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/anymaths_adapter.py +174 -0
  28. gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/requirements.txt +1 -0
  29. gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/README.md +0 -0
  30. gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/__init__.py +0 -0
  31. gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/default_adapter.py +209 -0
  32. gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/README.md +7 -0
  33. gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/__init__.py +0 -0
  34. gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/dspy_adapter.py +307 -0
  35. gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/README.md +99 -0
  36. gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/dspy_program_proposal_signature.py +137 -0
  37. gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/full_program_adapter.py +268 -0
  38. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/GEPA_RAG.md +621 -0
  39. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/__init__.py +56 -0
  40. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/evaluation_metrics.py +226 -0
  41. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/generic_rag_adapter.py +496 -0
  42. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/rag_pipeline.py +238 -0
  43. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_store_interface.py +212 -0
  44. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/__init__.py +2 -0
  45. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/chroma_store.py +196 -0
  46. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/lancedb_store.py +422 -0
  47. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/milvus_store.py +409 -0
  48. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/qdrant_store.py +368 -0
  49. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/weaviate_store.py +418 -0
  50. gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/README.md +552 -0
  51. gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/__init__.py +37 -0
  52. gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_adapter.py +699 -0
  53. gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_client.py +364 -0
  54. gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/README.md +9 -0
  55. gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/__init__.py +0 -0
  56. gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/terminal_bench_adapter.py +217 -0
  57. gatewaysdk/algorithm/gepa/lib/api.py +382 -0
  58. gatewaysdk/algorithm/gepa/lib/core/__init__.py +0 -0
  59. gatewaysdk/algorithm/gepa/lib/core/adapter.py +180 -0
  60. gatewaysdk/algorithm/gepa/lib/core/data_loader.py +74 -0
  61. gatewaysdk/algorithm/gepa/lib/core/engine.py +379 -0
  62. gatewaysdk/algorithm/gepa/lib/core/result.py +233 -0
  63. gatewaysdk/algorithm/gepa/lib/core/state.py +636 -0
  64. gatewaysdk/algorithm/gepa/lib/examples/__init__.py +0 -0
  65. gatewaysdk/algorithm/gepa/lib/examples/aime.py +24 -0
  66. gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/eval_default.py +111 -0
  67. gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/instruction_prompt.txt +9 -0
  68. gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/optimal_prompt.txt +24 -0
  69. gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/train_anymaths.py +177 -0
  70. gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/arc_agi.ipynb +25705 -0
  71. gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/example.ipynb +348 -0
  72. gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/__init__.py +4 -0
  73. gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/mcp_optimization_example.py +456 -0
  74. gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/RAG_GUIDE.md +613 -0
  75. gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/__init__.py +9 -0
  76. gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/rag_optimization.py +820 -0
  77. gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/requirements-rag.txt +29 -0
  78. gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/instruction_prompt.txt +16 -0
  79. gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/terminus.txt +9 -0
  80. gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/train_terminus.py +161 -0
  81. gatewaysdk/algorithm/gepa/lib/gepa_utils.py +117 -0
  82. gatewaysdk/algorithm/gepa/lib/logging/__init__.py +0 -0
  83. gatewaysdk/algorithm/gepa/lib/logging/experiment_tracker.py +187 -0
  84. gatewaysdk/algorithm/gepa/lib/logging/logger.py +75 -0
  85. gatewaysdk/algorithm/gepa/lib/logging/utils.py +103 -0
  86. gatewaysdk/algorithm/gepa/lib/proposer/__init__.py +0 -0
  87. gatewaysdk/algorithm/gepa/lib/proposer/base.py +31 -0
  88. gatewaysdk/algorithm/gepa/lib/proposer/merge.py +357 -0
  89. gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/__init__.py +0 -0
  90. gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/base.py +49 -0
  91. gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/reflective_mutation.py +176 -0
  92. gatewaysdk/algorithm/gepa/lib/py.typed +0 -0
  93. gatewaysdk/algorithm/gepa/lib/strategies/__init__.py +0 -0
  94. gatewaysdk/algorithm/gepa/lib/strategies/batch_sampler.py +77 -0
  95. gatewaysdk/algorithm/gepa/lib/strategies/candidate_selector.py +50 -0
  96. gatewaysdk/algorithm/gepa/lib/strategies/component_selector.py +36 -0
  97. gatewaysdk/algorithm/gepa/lib/strategies/eval_policy.py +64 -0
  98. gatewaysdk/algorithm/gepa/lib/strategies/instruction_proposal.py +126 -0
  99. gatewaysdk/algorithm/gepa/lib/utils/__init__.py +10 -0
  100. gatewaysdk/algorithm/gepa/lib/utils/stop_condition.py +196 -0
  101. gatewaysdk/algorithm/gepa/tracing.py +105 -0
  102. gatewaysdk/algorithm/utils.py +177 -0
  103. gatewaysdk/algorithm/verl/__init__.py +5 -0
  104. gatewaysdk/algorithm/verl/interface.py +202 -0
  105. gatewaysdk/automations.py +111 -0
  106. gatewaysdk/benchmark_hub/README.md +88 -0
  107. gatewaysdk/benchmark_hub/__init__.py +119 -0
  108. gatewaysdk/benchmark_hub/_archive.py +189 -0
  109. gatewaysdk/benchmark_hub/_tar.py +6 -0
  110. gatewaysdk/benchmark_hub/client.py +992 -0
  111. gatewaysdk/benchmark_hub/dispatch_shard.py +34 -0
  112. gatewaysdk/benchmark_hub/eval_config.py +20 -0
  113. gatewaysdk/benchmark_hub/evals.py +351 -0
  114. gatewaysdk/benchmark_hub/harbor_adapter.py +673 -0
  115. gatewaysdk/benchmark_hub/path_utils.py +81 -0
  116. gatewaysdk/benchmark_hub/save_utils.py +101 -0
  117. gatewaysdk/benchmark_hub/verifiers_adapter.py +327 -0
  118. gatewaysdk/benchmark_hub/versioning.py +72 -0
  119. gatewaysdk/build.py +515 -0
  120. gatewaysdk/cli/__init__.py +58 -0
  121. gatewaysdk/cli/agent_runner.py +132 -0
  122. gatewaysdk/cli/http_client.py +115 -0
  123. gatewaysdk/cli/platform.py +4086 -0
  124. gatewaysdk/cli/prometheus.py +115 -0
  125. gatewaysdk/cli/release_gate.py +215 -0
  126. gatewaysdk/cli/store.py +131 -0
  127. gatewaysdk/cli/vllm.py +29 -0
  128. gatewaysdk/client.py +406 -0
  129. gatewaysdk/config.py +348 -0
  130. gatewaysdk/connectors/__init__.py +25 -0
  131. gatewaysdk/connectors/client.py +203 -0
  132. gatewaysdk/connectors/skill.py +25 -0
  133. gatewaysdk/connectors/template.py +106 -0
  134. gatewaysdk/context.py +606 -0
  135. gatewaysdk/emitter/__init__.py +43 -0
  136. gatewaysdk/emitter/annotation.py +370 -0
  137. gatewaysdk/emitter/exception.py +54 -0
  138. gatewaysdk/emitter/message.py +61 -0
  139. gatewaysdk/emitter/object.py +117 -0
  140. gatewaysdk/emitter/reward.py +320 -0
  141. gatewaysdk/env_var.py +156 -0
  142. gatewaysdk/environment/__init__.py +108 -0
  143. gatewaysdk/environment/_bundle.py +281 -0
  144. gatewaysdk/environment/_harbor.py +276 -0
  145. gatewaysdk/environment/_materialize.py +97 -0
  146. gatewaysdk/environment/_tar.py +33 -0
  147. gatewaysdk/environment/_world.py +346 -0
  148. gatewaysdk/environment/core.py +527 -0
  149. gatewaysdk/environment/errors.py +58 -0
  150. gatewaysdk/environment/runtime.py +150 -0
  151. gatewaysdk/environment/schema/__init__.py +35 -0
  152. gatewaysdk/environment/schema/__main__.py +225 -0
  153. gatewaysdk/environment/schema/_slack_fidelity.py +116 -0
  154. gatewaysdk/environment/schema/_slack_scenario.py +183 -0
  155. gatewaysdk/environment/schema/api.py +1628 -0
  156. gatewaysdk/environment/schema/batch.py +755 -0
  157. gatewaysdk/environment/schema/compiler.py +615 -0
  158. gatewaysdk/environment/schema/conform.py +428 -0
  159. gatewaysdk/environment/schema/connector.py +777 -0
  160. gatewaysdk/environment/schema/connectors/apple-business-manager/handlers.py +17 -0
  161. gatewaysdk/environment/schema/connectors/apple-business-manager/parity.json +28 -0
  162. gatewaysdk/environment/schema/connectors/apple-business-manager/provenance.json +233 -0
  163. gatewaysdk/environment/schema/connectors/apple-business-manager/scope.toml +112 -0
  164. gatewaysdk/environment/schema/connectors/apple-business-manager/world.json +1380 -0
  165. gatewaysdk/environment/schema/connectors/base.json +28 -0
  166. gatewaysdk/environment/schema/connectors/custom/handlers.py +18 -0
  167. gatewaysdk/environment/schema/connectors/custom/world.json +123 -0
  168. gatewaysdk/environment/schema/connectors/github/handlers.py +81 -0
  169. gatewaysdk/environment/schema/connectors/github/parity.json +37 -0
  170. gatewaysdk/environment/schema/connectors/github/provenance.json +134 -0
  171. gatewaysdk/environment/schema/connectors/github/scope.toml +158 -0
  172. gatewaysdk/environment/schema/connectors/github/world.json +5869 -0
  173. gatewaysdk/environment/schema/connectors/google-calendar/handlers.py +23 -0
  174. gatewaysdk/environment/schema/connectors/google-calendar/world.json +209 -0
  175. gatewaysdk/environment/schema/connectors/google-drive/handlers.py +18 -0
  176. gatewaysdk/environment/schema/connectors/google-drive/world.json +286 -0
  177. gatewaysdk/environment/schema/connectors/jamf/handlers.py +34 -0
  178. gatewaysdk/environment/schema/connectors/jamf/parity.json +49 -0
  179. gatewaysdk/environment/schema/connectors/jamf/provenance.json +181 -0
  180. gatewaysdk/environment/schema/connectors/jamf/scope.toml +128 -0
  181. gatewaysdk/environment/schema/connectors/jamf/world.json +2218 -0
  182. gatewaysdk/environment/schema/connectors/jira/handlers.py +28 -0
  183. gatewaysdk/environment/schema/connectors/jira/world.json +389 -0
  184. gatewaysdk/environment/schema/connectors/kandji/handlers.py +38 -0
  185. gatewaysdk/environment/schema/connectors/kandji/parity.json +29 -0
  186. gatewaysdk/environment/schema/connectors/kandji/provenance.json +152 -0
  187. gatewaysdk/environment/schema/connectors/kandji/scope.toml +120 -0
  188. gatewaysdk/environment/schema/connectors/kandji/world.json +765 -0
  189. gatewaysdk/environment/schema/connectors/linear/handlers.py +20 -0
  190. gatewaysdk/environment/schema/connectors/linear/world.json +363 -0
  191. gatewaysdk/environment/schema/connectors/microsoft-teams/fidelity.json +45 -0
  192. gatewaysdk/environment/schema/connectors/microsoft-teams/handlers.py +1 -0
  193. gatewaysdk/environment/schema/connectors/microsoft-teams/provenance.json +851 -0
  194. gatewaysdk/environment/schema/connectors/microsoft-teams/scope.md +231 -0
  195. gatewaysdk/environment/schema/connectors/microsoft-teams/teams-types.json +1710 -0
  196. gatewaysdk/environment/schema/connectors/microsoft-teams/world.json +858 -0
  197. gatewaysdk/environment/schema/connectors/netsuite/handlers.py +32 -0
  198. gatewaysdk/environment/schema/connectors/netsuite/parity.json +50 -0
  199. gatewaysdk/environment/schema/connectors/netsuite/provenance.json +137 -0
  200. gatewaysdk/environment/schema/connectors/netsuite/scope.toml +78 -0
  201. gatewaysdk/environment/schema/connectors/netsuite/world.json +16243 -0
  202. gatewaysdk/environment/schema/connectors/salesforce/handlers.py +806 -0
  203. gatewaysdk/environment/schema/connectors/salesforce/parity.json +46 -0
  204. gatewaysdk/environment/schema/connectors/salesforce/provenance.json +143 -0
  205. gatewaysdk/environment/schema/connectors/salesforce/scope.toml +174 -0
  206. gatewaysdk/environment/schema/connectors/salesforce/world.json +4695 -0
  207. gatewaysdk/environment/schema/connectors/slack/MODEL.md +236 -0
  208. gatewaysdk/environment/schema/connectors/slack/capabilities.json +1039 -0
  209. gatewaysdk/environment/schema/connectors/slack/handlers.py +762 -0
  210. gatewaysdk/environment/schema/connectors/slack/parity.json +65 -0
  211. gatewaysdk/environment/schema/connectors/slack/provenance.json +294 -0
  212. gatewaysdk/environment/schema/connectors/slack/scope.toml +44 -0
  213. gatewaysdk/environment/schema/connectors/slack/world.json +4993 -0
  214. gatewaysdk/environment/schema/connectors/workday/handlers.py +75 -0
  215. gatewaysdk/environment/schema/connectors/workday/parity.json +36 -0
  216. gatewaysdk/environment/schema/connectors/workday/provenance.json +179 -0
  217. gatewaysdk/environment/schema/connectors/workday/scope.toml +180 -0
  218. gatewaysdk/environment/schema/connectors/workday/world.json +806 -0
  219. gatewaysdk/environment/schema/data.py +593 -0
  220. gatewaysdk/environment/schema/extract.py +263 -0
  221. gatewaysdk/environment/schema/host.py +2437 -0
  222. gatewaysdk/environment/schema/host_surfaces.py +275 -0
  223. gatewaysdk/environment/schema/platform.py +1272 -0
  224. gatewaysdk/environment/schema/scaffold.py +250 -0
  225. gatewaysdk/environment/schema/skill.py +118 -0
  226. gatewaysdk/environment/schema/skills/agents/connector-template-author.md +202 -0
  227. gatewaysdk/environment/schema/skills/connector-schema-authoring/CONNECTORS.md +331 -0
  228. gatewaysdk/environment/schema/skills/connector-schema-authoring/CONVENTIONS.md +100 -0
  229. gatewaysdk/environment/schema/skills/connector-schema-authoring/SKILL.md +313 -0
  230. gatewaysdk/environment/schema/skills/world-data-ingestion/ROWS.md +223 -0
  231. gatewaysdk/environment/schema/skills/world-data-ingestion/SKILL.md +293 -0
  232. gatewaysdk/environment/schema/skills/worlds-getting-started/SKILL.md +390 -0
  233. gatewaysdk/environment/schema/slack.py +757 -0
  234. gatewaysdk/environment/schema/snapshot.py +479 -0
  235. gatewaysdk/environment/schema/store.py +904 -0
  236. gatewaysdk/environment/schema/tasks.py +525 -0
  237. gatewaysdk/environment/schema/tools_world.py +322 -0
  238. gatewaysdk/environment/schema/validation.py +577 -0
  239. gatewaysdk/environment/schema/worker.py +78 -0
  240. gatewaysdk/environment/schema/workers.py +500 -0
  241. gatewaysdk/environment/schema/world_tests.py +336 -0
  242. gatewaysdk/environments/__init__.py +24 -0
  243. gatewaysdk/environments/client.py +486 -0
  244. gatewaysdk/environments/types.py +318 -0
  245. gatewaysdk/execution/__init__.py +15 -0
  246. gatewaysdk/execution/base.py +64 -0
  247. gatewaysdk/execution/client_server.py +443 -0
  248. gatewaysdk/execution/events.py +69 -0
  249. gatewaysdk/execution/inter_process.py +16 -0
  250. gatewaysdk/execution/shared_memory.py +282 -0
  251. gatewaysdk/experiments/__init__.py +90 -0
  252. gatewaysdk/experiments/assignment.py +177 -0
  253. gatewaysdk/experiments/client.py +877 -0
  254. gatewaysdk/experiments/exposure.py +222 -0
  255. gatewaysdk/experiments/types.py +81 -0
  256. gatewaysdk/importers/__init__.py +49 -0
  257. gatewaysdk/importers/_normalize.py +91 -0
  258. gatewaysdk/importers/builder.py +173 -0
  259. gatewaysdk/importers/client.py +95 -0
  260. gatewaysdk/importers/langsmith.py +196 -0
  261. gatewaysdk/importers/recipes.py +249 -0
  262. gatewaysdk/instrumentation/__init__.py +300 -0
  263. gatewaysdk/instrumentation/agentops.py +314 -0
  264. gatewaysdk/instrumentation/agentops_langchain.py +45 -0
  265. gatewaysdk/instrumentation/base.py +119 -0
  266. gatewaysdk/instrumentation/litellm.py +83 -0
  267. gatewaysdk/instrumentation/registry.py +273 -0
  268. gatewaysdk/instrumentation/vllm.py +81 -0
  269. gatewaysdk/instrumentation/weave.py +500 -0
  270. gatewaysdk/integrations/__init__.py +15 -0
  271. gatewaysdk/integrations/gateway/__init__.py +11 -0
  272. gatewaysdk/integrations/gateway/client.py +171 -0
  273. gatewaysdk/integrations/tool_access.py +549 -0
  274. gatewaysdk/litagent/__init__.py +11 -0
  275. gatewaysdk/litagent/decorator.py +536 -0
  276. gatewaysdk/litagent/litagent.py +252 -0
  277. gatewaysdk/llm_proxy.py +1742 -0
  278. gatewaysdk/logging.py +370 -0
  279. gatewaysdk/memory.py +278 -0
  280. gatewaysdk/personas.py +186 -0
  281. gatewaysdk/platform/__init__.py +17 -0
  282. gatewaysdk/platform/builder.py +221 -0
  283. gatewaysdk/platform/compatibility.py +67 -0
  284. gatewaysdk/platform/manifest.py +185 -0
  285. gatewaysdk/platform/orchestrator.py +901 -0
  286. gatewaysdk/platform/registry.py +122 -0
  287. gatewaysdk/platform/worker.py +864 -0
  288. gatewaysdk/replay/__init__.py +1032 -0
  289. gatewaysdk/replay/pytest.py +56 -0
  290. gatewaysdk/reward.py +7 -0
  291. gatewaysdk/run.py +1781 -0
  292. gatewaysdk/runner/__init__.py +11 -0
  293. gatewaysdk/runner/agent.py +878 -0
  294. gatewaysdk/runner/base.py +182 -0
  295. gatewaysdk/runner/legacy.py +309 -0
  296. gatewaysdk/security.py +700 -0
  297. gatewaysdk/semconv.py +170 -0
  298. gatewaysdk/server.py +399 -0
  299. gatewaysdk/sessions.py +282 -0
  300. gatewaysdk/store/__init__.py +45 -0
  301. gatewaysdk/store/base.py +908 -0
  302. gatewaysdk/store/client_server.py +2093 -0
  303. gatewaysdk/store/collection/__init__.py +30 -0
  304. gatewaysdk/store/collection/base.py +587 -0
  305. gatewaysdk/store/collection/memory.py +970 -0
  306. gatewaysdk/store/collection/mongo.py +1412 -0
  307. gatewaysdk/store/collection_based.py +1823 -0
  308. gatewaysdk/store/gateway.py +983 -0
  309. gatewaysdk/store/gateway_listener.py +465 -0
  310. gatewaysdk/store/listener.py +58 -0
  311. gatewaysdk/store/memory.py +396 -0
  312. gatewaysdk/store/mongo.py +165 -0
  313. gatewaysdk/store/redis_stream.py +517 -0
  314. gatewaysdk/store/sqlite.py +3 -0
  315. gatewaysdk/store/threading.py +370 -0
  316. gatewaysdk/store/utils.py +142 -0
  317. gatewaysdk/tracer/__init__.py +14 -0
  318. gatewaysdk/tracer/base.py +286 -0
  319. gatewaysdk/tracer/dummy.py +106 -0
  320. gatewaysdk/tracer/otel.py +559 -0
  321. gatewaysdk/tracing/__init__.py +110 -0
  322. gatewaysdk/tracing/api.py +808 -0
  323. gatewaysdk/tracing/attributes.py +9 -0
  324. gatewaysdk/tracing/context.py +272 -0
  325. gatewaysdk/tracing/exporters/__init__.py +10 -0
  326. gatewaysdk/tracing/exporters/gateway.py +228 -0
  327. gatewaysdk/tracing/identity.py +288 -0
  328. gatewaysdk/tracing/init.py +620 -0
  329. gatewaysdk/tracing/instrumentors/__init__.py +15 -0
  330. gatewaysdk/tracing/instrumentors/claude_agent_sdk.py +766 -0
  331. gatewaysdk/tracing/instrumentors/instrumentation_principles.md +294 -0
  332. gatewaysdk/tracing/instrumentors/registry.py +352 -0
  333. gatewaysdk/tracing/mapping.py +729 -0
  334. gatewaysdk/tracing/processors.py +393 -0
  335. gatewaysdk/tracing/push.py +617 -0
  336. gatewaysdk/tracing/push_models.py +247 -0
  337. gatewaysdk/tracing/semconv.py +294 -0
  338. gatewaysdk/tracing/span_builder.py +356 -0
  339. gatewaysdk/trainer/__init__.py +6 -0
  340. gatewaysdk/trainer/init_utils.py +263 -0
  341. gatewaysdk/trainer/legacy.py +359 -0
  342. gatewaysdk/trainer/registry.py +12 -0
  343. gatewaysdk/trainer/trainer.py +638 -0
  344. gatewaysdk/types/__init__.py +63 -0
  345. gatewaysdk/types/core.py +556 -0
  346. gatewaysdk/types/resources.py +204 -0
  347. gatewaysdk/types/tracer.py +515 -0
  348. gatewaysdk/types/tracing.py +162 -0
  349. gatewaysdk/users.py +251 -0
  350. gatewaysdk/utils/__init__.py +1 -0
  351. gatewaysdk/utils/id.py +18 -0
  352. gatewaysdk/utils/metrics.py +1025 -0
  353. gatewaysdk/utils/otel.py +550 -0
  354. gatewaysdk/utils/otlp.py +556 -0
  355. gatewaysdk/utils/redact.py +22 -0
  356. gatewaysdk/utils/server_launcher.py +1045 -0
  357. gatewaysdk/utils/system_snapshot.py +90 -0
  358. gatewaysdk/verl/__init__.py +8 -0
  359. gatewaysdk/verl/__main__.py +6 -0
  360. gatewaysdk/verl/async_server.py +46 -0
  361. gatewaysdk/verl/config.yaml +27 -0
  362. gatewaysdk/verl/daemon.py +1154 -0
  363. gatewaysdk/verl/dataset.py +44 -0
  364. gatewaysdk/verl/entrypoint.py +248 -0
  365. gatewaysdk/verl/trainer.py +549 -0
  366. gatewaysdk/world_browser.py +748 -0
  367. gatewaysdk/world_data.py +453 -0
  368. gatewaysdk/world_sessions.py +978 -0
  369. gatewaysdk/world_tasks.py +285 -0
  370. gatewaysdk/world_tools.py +115 -0
  371. gatewaysdk/worlds.py +639 -0
  372. gatewaysdk-0.3.2.dist-info/METADATA +236 -0
  373. gatewaysdk-0.3.2.dist-info/RECORD +376 -0
  374. gatewaysdk-0.3.2.dist-info/WHEEL +4 -0
  375. gatewaysdk-0.3.2.dist-info/entry_points.txt +5 -0
  376. gatewaysdk-0.3.2.dist-info/licenses/LICENSE +19 -0
@@ -0,0 +1,777 @@
1
+ """Custom connections: describe a vendor API once, capture what the agent's
2
+ operations return, and seed the world from those captures through the schema.
3
+
4
+ The same ``connector.toml`` serves the CLI here, the world's own API
5
+ (``gateway worlds serve``) and the platform's custom source later, so a
6
+ connection is described once::
7
+
8
+ [connector]
9
+ slug = "spycloud"
10
+ base_url = "https://api.spycloud.io/investigations-v2"
11
+
12
+ [auth]
13
+ scheme = "header" # header | bearer | basic | query | none
14
+ name = "x-api-key" # header or query parameter that carries the secret
15
+ secret_env = "SPYCLOUD_API_KEY"
16
+ headers = { "X-App-Token" = "SPYCLOUD_APP_TOKEN" } # more credential headers, each named by an env var
17
+
18
+ [capture]
19
+ requests_per_minute = 30 # pacing between requests; providers ask us to be sparing
20
+ timeout_seconds = 30
21
+ max_pages = 5
22
+ cursor_param = "cursor" # optional: cursor pagination, request side
23
+ cursor_field = "cursor" # optional: cursor pagination, response side ('' or null ends)
24
+
25
+ [redact]
26
+ hash = ["email", "password"] # replaced by "sha256:<hex>" salted with the slug, at any depth
27
+ drop = ["ssn"] # removed at any depth
28
+
29
+ [[operations]]
30
+ name = "get_records_by_email"
31
+ method = "GET"
32
+ path = "/breach/data/emails/{email}"
33
+ params = ["since", "until", "severity"]
34
+ entity = "spycloud_records" # optional: rows land in this entity on ingest
35
+ results = "results" # dotted path to the row array; "" means the body is the row
36
+ [operations.ingest]
37
+ drop = ["channels"] # vendor fields the entity does not carry (they are modelled elsewhere)
38
+ [[operations.ingest.explode]]
39
+ field = "channels" # a nested array on the row becomes rows of a child entity
40
+ entity = "spycloud_memberships"
41
+ carry = { user_id = "id" } # child field = parent row field
42
+ take = { channel_id = "id" } # child field = nested item field
43
+
44
+ Captures are written under ``captures/<operation>/`` with the secret stripped
45
+ from the recorded request, a redacted body, and a manifest; every response is
46
+ recorded, including errors, because the vendor's error bodies are part of what
47
+ the world must reproduce. ``ingest`` turns the successful captures into the
48
+ world's ``data/initial.json`` batch, proving it applies through the contract in
49
+ a scratch database before the file is written. Nothing here imports beyond the
50
+ standard library, so the credential-free bridge can load it.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import base64
56
+ import hashlib
57
+ import json
58
+ import os
59
+ import re
60
+ import shutil
61
+ import tempfile
62
+ import time
63
+ import tomllib
64
+ import urllib.error
65
+ import urllib.parse
66
+ import urllib.request
67
+ from dataclasses import dataclass, field
68
+ from datetime import datetime, timezone
69
+ from pathlib import Path
70
+ from typing import Any, Callable
71
+
72
+ from .compiler import check_bundle, compile_bundle
73
+ from .store import SQLiteStore
74
+ from .validation import fail
75
+
76
+ SPEC = "connector.toml"
77
+ CAPTURES = "captures"
78
+ MANIFEST = "manifest.json"
79
+ SCHEMES = ("header", "bearer", "basic", "query", "oauth2", "none")
80
+ DEFAULT_TOKEN_PATH = "/oauth/token"
81
+ METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
82
+ SLUG = re.compile(r"^[a-z][a-z0-9-]*$")
83
+ NAME = re.compile(r"^[a-z][a-z0-9_]*$")
84
+ #: Vendor-named things — query parameters, path arguments, redacted fields — keep the
85
+ #: vendor's casing (``startDate``, ``Limit``); only our own operation names are snake_case.
86
+ FIELD = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
87
+ PLACEHOLDER = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
88
+ MAX_BODY = 8 * 1024 * 1024
89
+ _PAGING_NAMES = ("limit", "offset", "page", "page_size", "cursor")
90
+ RECORDED_HEADERS = ("content-type", "retry-after", "x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset")
91
+
92
+ Opener = Callable[[urllib.request.Request, float], "Response"]
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Response:
97
+ status: int
98
+ headers: dict[str, str]
99
+ body: bytes
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Explode:
104
+ field: str
105
+ entity: str
106
+ carry: dict[str, str]
107
+ take: dict[str, str]
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Operation:
112
+ name: str
113
+ method: str
114
+ path: str
115
+ params: tuple[str, ...]
116
+ entity: str | None
117
+ results: str
118
+ drop: tuple[str, ...] = ()
119
+ explode: tuple[Explode, ...] = ()
120
+
121
+ @property
122
+ def placeholders(self) -> tuple[str, ...]:
123
+ return tuple(PLACEHOLDER.findall(self.path))
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Spec:
128
+ slug: str
129
+ base_url: str
130
+ scheme: str
131
+ auth_name: str | None
132
+ secret_env: str | None
133
+ extra_headers: dict[str, str] # header name -> env var holding its value
134
+ #: oauth2 only: where the client-credentials grant is exchanged (absolute, or a
135
+ #: path under base_url), the env var naming the client id, and the scope asked for.
136
+ token_url: str | None
137
+ client_id_env: str | None
138
+ scope: str | None
139
+ requests_per_minute: int
140
+ timeout_seconds: float
141
+ max_pages: int
142
+ cursor_param: str | None
143
+ cursor_field: str | None
144
+ cursor_header: str | None
145
+ hash_fields: frozenset[str]
146
+ drop_fields: frozenset[str]
147
+ round_fields: dict[str, int] = field(default_factory=dict) # numeric field -> decimals kept
148
+ operations: dict[str, Operation] = field(default_factory=dict)
149
+
150
+ def operation(self, name: str) -> Operation:
151
+ if name not in self.operations:
152
+ fail("operation", f"Unknown operation; choose one of {', '.join(sorted(self.operations))}", "/capture/op")
153
+ return self.operations[name]
154
+
155
+
156
+ # ---------------------------------------------------------------- spec
157
+
158
+
159
+ def _table(value: Any, location: str) -> dict:
160
+ if not isinstance(value, dict):
161
+ fail("spec", "Expected a table", location)
162
+ return value
163
+
164
+
165
+ def _string(table: dict, key: str, location: str, *, default: str | None = None, required: bool = True) -> str | None:
166
+ if key not in table:
167
+ if required:
168
+ fail("spec", f"Missing {key}", location)
169
+ return default
170
+ value = table[key]
171
+ if not isinstance(value, str) or not value.strip():
172
+ fail("spec", f"{key} must be a non-empty string", f"{location}/{key}")
173
+ return value.strip()
174
+
175
+
176
+ def _paths(table: dict, key: str, location: str) -> tuple[str, ...]:
177
+ """Redaction names: a bare field name matches at any depth; a dotted path
178
+ (``results.*.email``, ``*`` for list items) matches that place only, so the
179
+ catalog's ``assets`` count map keeps its ``email`` key while records lose theirs."""
180
+ value = table.get(key, [])
181
+ if not isinstance(value, list) or not all(
182
+ isinstance(v, str) and all(part == "*" or FIELD.match(part) for part in v.split(".")) and v for v in value
183
+ ):
184
+ fail("spec", f"{key} must be a list of field names or dotted paths", f"{location}/{key}")
185
+ return tuple(value)
186
+
187
+
188
+ def _names(table: dict, key: str, location: str) -> tuple[str, ...]:
189
+ value = table.get(key, [])
190
+ if not isinstance(value, list) or not all(isinstance(v, str) and FIELD.match(v) for v in value):
191
+ fail("spec", f"{key} must be a list of snake_case names", f"{location}/{key}")
192
+ if len(set(value)) != len(value):
193
+ fail("spec", f"{key} repeats a name", f"{location}/{key}")
194
+ return tuple(value)
195
+
196
+
197
+ def _positive(table: dict, key: str, location: str, default: int | float, *, zero_ok: bool = False) -> int | float:
198
+ value = table.get(key, default)
199
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0 or (value == 0 and not zero_ok):
200
+ fail("spec", f"{key} must be a positive number", f"{location}/{key}")
201
+ return value
202
+
203
+
204
+ def parse_spec(text: str) -> Spec:
205
+ try:
206
+ raw = tomllib.loads(text)
207
+ except tomllib.TOMLDecodeError as error:
208
+ fail("spec", f"connector.toml is not valid TOML: {error}", "/connector.toml")
209
+ connector = _table(raw.get("connector"), "/connector")
210
+ slug = _string(connector, "slug", "/connector")
211
+ if not SLUG.match(slug):
212
+ fail("spec", "slug must be lowercase letters, digits and hyphens", "/connector/slug")
213
+ base_url = _string(connector, "base_url", "/connector")
214
+ parsed = urllib.parse.urlsplit(base_url)
215
+ if parsed.scheme not in ("https", "http") or not parsed.netloc or parsed.query or parsed.fragment:
216
+ fail("spec", "base_url must be an http(s) origin with an optional path and no query", "/connector/base_url")
217
+
218
+ auth = _table(raw.get("auth", {"scheme": "none"}), "/auth")
219
+ scheme = _string(auth, "scheme", "/auth")
220
+ if scheme not in SCHEMES:
221
+ fail("spec", f"auth.scheme must be one of {', '.join(SCHEMES)}", "/auth/scheme")
222
+ auth_name = _string(auth, "name", "/auth", required=scheme in ("header", "query"))
223
+ secret_env = _string(auth, "secret_env", "/auth", required=scheme != "none")
224
+ if scheme == "none" and (auth_name or secret_env):
225
+ fail("spec", "auth.scheme none takes no name or secret_env", "/auth")
226
+ token_url = _string(auth, "token_url", "/auth", default=DEFAULT_TOKEN_PATH, required=False)
227
+ client_id_env = _string(auth, "client_id_env", "/auth", required=scheme == "oauth2")
228
+ scope = _string(auth, "scope", "/auth", required=False)
229
+ if scheme != "oauth2" and (client_id_env or scope or "token_url" in auth):
230
+ fail("spec", "token_url, client_id_env and scope belong to auth.scheme oauth2", "/auth")
231
+ if scheme == "oauth2" and not (token_url or "").startswith(("/", "https://", "http://")):
232
+ fail("spec", "auth.token_url is a path under base_url or an absolute URL", "/auth/token_url")
233
+ extra_headers = auth.get("headers", {})
234
+ if not isinstance(extra_headers, dict) or not all(
235
+ isinstance(k, str) and k.strip() and isinstance(v, str) and v.strip() for k, v in extra_headers.items()
236
+ ):
237
+ fail("spec", "auth.headers maps header names to environment variable names", "/auth/headers")
238
+ if auth_name and scheme == "header" and any(k.lower() == auth_name.lower() for k in extra_headers):
239
+ fail("spec", "auth.headers repeats the secret header", "/auth/headers")
240
+
241
+ capture = _table(raw.get("capture", {}), "/capture")
242
+ cursor_param = _string(capture, "cursor_param", "/capture", required=False)
243
+ cursor_field = _string(capture, "cursor_field", "/capture", required=False)
244
+ cursor_header = _string(capture, "cursor_header", "/capture", required=False)
245
+ if cursor_field and cursor_header:
246
+ fail("spec", "The next cursor is read from a body field or a response header, not both", "/capture")
247
+ if bool(cursor_param) != bool(cursor_field or cursor_header):
248
+ fail("spec", "cursor_param comes with cursor_field or cursor_header", "/capture")
249
+
250
+ redact = _table(raw.get("redact", {}), "/redact")
251
+ hash_fields = frozenset(_paths(redact, "hash", "/redact"))
252
+ drop_fields = frozenset(_paths(redact, "drop", "/redact"))
253
+ rounding = redact.get("round", {})
254
+ if not isinstance(rounding, dict) or not all(
255
+ isinstance(k, str) and FIELD.match(k) and isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 10
256
+ for k, v in rounding.items()
257
+ ):
258
+ fail("spec", "redact.round maps numeric field names to the decimals kept (0-10)", "/redact/round")
259
+ round_fields = dict(rounding)
260
+ if (hash_fields & drop_fields) or (set(round_fields) & (hash_fields | drop_fields)):
261
+ fail("spec", "A field is hashed, dropped or rounded, never two of those", "/redact")
262
+
263
+ operations: dict[str, Operation] = {}
264
+ listed = raw.get("operations", [])
265
+ if not isinstance(listed, list) or not listed:
266
+ fail("spec", "Declare at least one [[operations]] entry", "/operations")
267
+ for index, entry in enumerate(listed):
268
+ location = f"/operations/{index}"
269
+ table = _table(entry, location)
270
+ name = _string(table, "name", location)
271
+ if not NAME.match(name):
272
+ fail("spec", "operation name must be snake_case", f"{location}/name")
273
+ if name in operations:
274
+ fail("spec", f"operation {name} is declared twice", f"{location}/name")
275
+ method = _string(table, "method", location, default="GET", required=False).upper()
276
+ if method not in METHODS:
277
+ fail("spec", f"method must be one of {', '.join(METHODS)}", f"{location}/method")
278
+ path = _string(table, "path", location)
279
+ if not path.startswith("/") or "?" in path or "#" in path:
280
+ fail("spec", "path must start with / and carry no query", f"{location}/path")
281
+ params = _names(table, "params", location)
282
+ entity = _string(table, "entity", location, required=False)
283
+ results = table.get("results", "")
284
+ if not isinstance(results, str):
285
+ fail("spec", "results must be a dotted path string", f"{location}/results")
286
+ for placeholder in PLACEHOLDER.findall(path):
287
+ if placeholder in params:
288
+ fail("spec", f"{placeholder} is both a path argument and a query parameter", location)
289
+ drop, explode = _ingest_projection(table.get("ingest", {}), f"{location}/ingest")
290
+ if (drop or explode) and not entity:
291
+ fail("spec", "ingest projection needs an entity to land rows in", f"{location}/ingest")
292
+ operations[name] = Operation(name, method, path, params, entity, results.strip("."), drop, explode)
293
+
294
+ return Spec(
295
+ slug=slug,
296
+ base_url=base_url.rstrip("/"),
297
+ scheme=scheme,
298
+ auth_name=auth_name,
299
+ secret_env=secret_env,
300
+ extra_headers=dict(extra_headers),
301
+ token_url=token_url if scheme == "oauth2" else None,
302
+ client_id_env=client_id_env if scheme == "oauth2" else None,
303
+ scope=scope if scheme == "oauth2" else None,
304
+ requests_per_minute=int(_positive(capture, "requests_per_minute", "/capture", 30)),
305
+ timeout_seconds=float(_positive(capture, "timeout_seconds", "/capture", 30)),
306
+ max_pages=int(_positive(capture, "max_pages", "/capture", 5)),
307
+ cursor_param=cursor_param,
308
+ cursor_field=cursor_field,
309
+ cursor_header=cursor_header,
310
+ hash_fields=hash_fields,
311
+ drop_fields=drop_fields,
312
+ round_fields=round_fields,
313
+ operations=operations,
314
+ )
315
+
316
+
317
+ def _mapping(table: dict, key: str, location: str) -> dict[str, str]:
318
+ value = table.get(key, {})
319
+ if not isinstance(value, dict) or not all(
320
+ isinstance(k, str) and isinstance(v, str) and k and v for k, v in value.items()
321
+ ):
322
+ fail("spec", f"{key} maps child field names to source field names", f"{location}/{key}")
323
+ return dict(value)
324
+
325
+
326
+ def _ingest_projection(raw: Any, location: str) -> tuple[tuple[str, ...], tuple[Explode, ...]]:
327
+ table = _table(raw, location)
328
+ drop = _names(table, "drop", location)
329
+ explode: list[Explode] = []
330
+ listed = table.get("explode", [])
331
+ if not isinstance(listed, list):
332
+ fail("spec", "explode must be a list of tables", f"{location}/explode")
333
+ for index, entry in enumerate(listed):
334
+ where = f"{location}/explode/{index}"
335
+ item = _table(entry, where)
336
+ field_name = _string(item, "field", where)
337
+ entity = _string(item, "entity", where)
338
+ carry = _mapping(item, "carry", where)
339
+ take = _mapping(item, "take", where)
340
+ if not carry and not take:
341
+ fail("spec", "explode needs carry or take, or the child rows would be empty", where)
342
+ if set(carry) & set(take):
343
+ fail("spec", "a child field is either carried from the parent or taken from the item", where)
344
+ explode.append(Explode(field_name, entity, carry, take))
345
+ return drop, tuple(explode)
346
+
347
+
348
+ def load_spec(world: str | Path) -> Spec:
349
+ path = Path(world) / SPEC
350
+ if not path.is_file():
351
+ fail("spec", f"No {SPEC} in the world; describe the connection first", f"/{SPEC}")
352
+ return parse_spec(path.read_text(encoding="utf-8"))
353
+
354
+
355
+ # ---------------------------------------------------------------- requests
356
+
357
+
358
+ def _secret_value(spec: Spec, secret: str | None) -> str:
359
+ if secret is not None:
360
+ return secret
361
+ value = os.environ.get(spec.secret_env or "")
362
+ if not value:
363
+ fail("secret", f"Set {spec.secret_env} to the connection's credential", "/auth/secret_env")
364
+ return value
365
+
366
+
367
+ def _secret(spec: Spec, secret: str | None, opener: "Opener | None" = None) -> str | None:
368
+ """The value the requests carry: the credential itself, or for oauth2 the
369
+ access token minted from it."""
370
+ if spec.scheme == "none":
371
+ return None
372
+ if spec.scheme == "oauth2":
373
+ return mint_token(spec, secret=secret, opener=opener)
374
+ return _secret_value(spec, secret)
375
+
376
+
377
+ def build_request(
378
+ spec: Spec,
379
+ operation: Operation,
380
+ args: dict[str, str],
381
+ params: dict[str, str],
382
+ *,
383
+ secret: str | None,
384
+ ) -> tuple[urllib.request.Request, str]:
385
+ """The request to send and the URL to record, which never carries the secret."""
386
+ missing = [name for name in operation.placeholders if name not in args]
387
+ if missing:
388
+ fail("capture", f"Missing path argument(s): {', '.join(missing)}", "/capture/args")
389
+ extra = [name for name in args if name not in operation.placeholders]
390
+ if extra:
391
+ fail("capture", f"Unknown path argument(s): {', '.join(extra)}", "/capture/args")
392
+ unknown = [name for name in params if name not in operation.params and name != spec.cursor_param]
393
+ if unknown and spec.cursor_field and any(u for u in unknown):
394
+ # A next-page URL may carry the vendor's own paging parameters; they are not ours to refuse.
395
+ unknown = [name for name in unknown if name not in _PAGING_NAMES]
396
+ if unknown:
397
+ fail("capture", f"Operation {operation.name} takes no parameter(s): {', '.join(unknown)}", "/capture/params")
398
+ path = PLACEHOLDER.sub(lambda m: urllib.parse.quote(args[m.group(1)], safe=""), operation.path)
399
+ query = [(key, value) for key, value in params.items()]
400
+ recorded = spec.base_url + path + (f"?{urllib.parse.urlencode(query)}" if query else "")
401
+ headers = {"Accept": "application/json", "User-Agent": f"gateway-connector/{spec.slug}"}
402
+ for header, variable in spec.extra_headers.items():
403
+ value = os.environ.get(variable)
404
+ if not value:
405
+ fail("secret", f"Set {variable} for the {header} header", "/auth/headers")
406
+ headers[header] = value
407
+ if spec.scheme == "header":
408
+ headers[spec.auth_name or ""] = secret or ""
409
+ elif spec.scheme in ("bearer", "oauth2"):
410
+ headers["Authorization"] = f"Bearer {secret}"
411
+ elif spec.scheme == "basic":
412
+ headers["Authorization"] = "Basic " + base64.b64encode((secret or "").encode()).decode()
413
+ elif spec.scheme == "query":
414
+ query.append((spec.auth_name or "", secret or ""))
415
+ url = spec.base_url + path + (f"?{urllib.parse.urlencode(query)}" if query else "")
416
+ return urllib.request.Request(url, method=operation.method, headers=headers), recorded
417
+
418
+
419
+ def token_endpoint(spec: Spec) -> str:
420
+ """Where an oauth2 connection exchanges its client credentials."""
421
+ url = spec.token_url or DEFAULT_TOKEN_PATH
422
+ return url if url.startswith(("https://", "http://")) else spec.base_url + url
423
+
424
+
425
+ def mint_token(spec: Spec, *, secret: str | None = None, opener: "Opener | None" = None) -> str:
426
+ """Exchange the client credentials for an access token (RFC 6749 §4.4).
427
+
428
+ The client id and secret come from the environment the way every other
429
+ credential does; the token is what the captures then send as a bearer.
430
+ Nothing about the exchange is recorded, so the secret never reaches disk."""
431
+ if spec.scheme != "oauth2":
432
+ fail("secret", "Only an oauth2 connection mints tokens", "/auth/scheme")
433
+ client_id = os.environ.get(spec.client_id_env or "")
434
+ if not client_id:
435
+ fail("secret", f"Set {spec.client_id_env} to the connection's client id", "/auth/client_id_env")
436
+ client_secret = _secret_value(spec, secret)
437
+ form = {"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}
438
+ if spec.scope:
439
+ form["scope"] = spec.scope
440
+ request = urllib.request.Request(
441
+ token_endpoint(spec),
442
+ method="POST",
443
+ data=urllib.parse.urlencode(form).encode(),
444
+ headers={
445
+ "Accept": "application/json",
446
+ "Content-Type": "application/x-www-form-urlencoded",
447
+ "User-Agent": f"gateway-connector/{spec.slug}",
448
+ },
449
+ )
450
+ response = (opener or _open)(request, spec.timeout_seconds)
451
+ try:
452
+ body = json.loads(response.body.decode("utf-8")) if response.body else {}
453
+ except (UnicodeDecodeError, json.JSONDecodeError):
454
+ body = {}
455
+ token = body.get("access_token") if isinstance(body, dict) else None
456
+ if not (200 <= response.status < 300) or not isinstance(token, str) or not token:
457
+ fail("secret", f"Token endpoint answered {response.status} without an access_token", "/auth/token_url")
458
+ return token
459
+
460
+
461
+ def _open(request: urllib.request.Request, timeout: float) -> Response:
462
+ try:
463
+ with urllib.request.urlopen(request, timeout=timeout) as reply:
464
+ return Response(reply.status, {k.lower(): v for k, v in reply.headers.items()}, reply.read(MAX_BODY + 1))
465
+ except urllib.error.HTTPError as error:
466
+ return Response(error.code, {k.lower(): v for k, v in error.headers.items()}, error.read(MAX_BODY + 1))
467
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
468
+ fail("transport", f"Could not reach the connection: {error}", "/capture")
469
+
470
+
471
+ # ---------------------------------------------------------------- redaction
472
+
473
+
474
+ def _named(key: str, here: str, names: frozenset[str]) -> bool:
475
+ return key in names or here in names
476
+
477
+
478
+ def _digest(slug: str, item: Any) -> Any:
479
+ """The hash of a value, keeping its shape: a list of strings hashes element by
480
+ element (an array stays an array, so the world's contract still accepts the row);
481
+ objects are left alone — name their fields instead."""
482
+ if isinstance(item, list):
483
+ return [_digest(slug, element) for element in item]
484
+ if isinstance(item, dict) or item is None:
485
+ return item
486
+ return f"sha256:{hashlib.sha256(f'{slug}:{item}'.encode()).hexdigest()}"
487
+
488
+
489
+ def redact(value: Any, spec: Spec, path: str = "") -> Any:
490
+ """Hash, drop or round the named fields; everything else is untouched.
491
+
492
+ A bare name applies at any depth; a dotted path applies at that place only, with
493
+ ``*`` standing for a list item."""
494
+ if isinstance(value, dict):
495
+ out = {}
496
+ for key, item in value.items():
497
+ here = f"{path}.{key}" if path else key
498
+ if _named(key, here, spec.drop_fields):
499
+ continue
500
+ if _named(key, here, spec.hash_fields) and item is not None:
501
+ out[key] = _digest(spec.slug, item)
502
+ elif key in spec.round_fields and isinstance(item, (int, float)) and not isinstance(item, bool):
503
+ # Coordinates and the like: keep the shape and the type, lose the precision.
504
+ out[key] = round(item, spec.round_fields[key])
505
+ else:
506
+ out[key] = redact(item, spec, here)
507
+ return out
508
+ if isinstance(value, list):
509
+ return [redact(item, spec, f"{path}.*" if path else "*") for item in value]
510
+ return value
511
+
512
+
513
+ def _dig(body: Any, path: str) -> Any:
514
+ for part in [p for p in path.split(".") if p]:
515
+ if not isinstance(body, dict) or part not in body:
516
+ return None
517
+ body = body[part]
518
+ return body
519
+
520
+
521
+ # ---------------------------------------------------------------- capture
522
+
523
+
524
+ def capture(
525
+ world: str | Path,
526
+ op: str,
527
+ args: dict[str, str] | None = None,
528
+ params: dict[str, str] | None = None,
529
+ *,
530
+ secret: str | None = None,
531
+ max_pages: int | None = None,
532
+ opener: Opener = _open,
533
+ sleep: Callable[[float], None] = time.sleep,
534
+ ) -> list[Path]:
535
+ """Run one operation against the live connection and record every page.
536
+
537
+ Returns the capture files written. Pagination follows the spec's cursor;
538
+ a non-2xx reply is recorded and ends the run, since error bodies are
539
+ evidence too.
540
+ """
541
+ root = Path(world)
542
+ spec = load_spec(root)
543
+ operation = spec.operation(op)
544
+ args = dict(args or {})
545
+ params = dict(params or {})
546
+ credential = _secret(spec, secret, opener)
547
+ pages = max_pages if max_pages is not None else spec.max_pages
548
+ if pages < 1:
549
+ fail("capture", "max_pages must be at least 1", "/capture/max_pages")
550
+ pause = 60.0 / spec.requests_per_minute
551
+ written: list[Path] = []
552
+ cursor: str | None = None
553
+ for page in range(pages):
554
+ if page:
555
+ sleep(pause)
556
+ page_params = dict(params)
557
+ if cursor and cursor.startswith(("https://", "http://")):
558
+ # A vendor that hands back the next page as a URL (DRF's ``next``): follow its
559
+ # query, never its host — the page must come from the connection described.
560
+ following = urllib.parse.urlsplit(cursor)
561
+ if following.netloc != urllib.parse.urlsplit(spec.base_url).netloc:
562
+ fail("capture", f"The next-page URL points at {following.netloc}, not the connection", "/capture")
563
+ page_params = {k: v for k, v in urllib.parse.parse_qsl(following.query) if k != (spec.auth_name or "")}
564
+ elif cursor:
565
+ page_params[spec.cursor_param or ""] = cursor
566
+ request, recorded = build_request(spec, operation, args, page_params, secret=credential)
567
+ response = opener(request, spec.timeout_seconds)
568
+ if len(response.body) > MAX_BODY:
569
+ fail("capture", "Response exceeds 8 MiB; narrow the request", "/capture")
570
+ try:
571
+ body: Any = json.loads(response.body.decode("utf-8")) if response.body else None
572
+ raw_text = None
573
+ except (UnicodeDecodeError, json.JSONDecodeError):
574
+ body, raw_text = None, response.body.decode("utf-8", "replace")[:4096]
575
+ redacted = redact(body, spec)
576
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
577
+ record = {
578
+ "connector": spec.slug,
579
+ "operation": operation.name,
580
+ "captured_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
581
+ "request": {"method": operation.method, "url": recorded, "args": args, "params": page_params},
582
+ "status": response.status,
583
+ "headers": {
584
+ k: v
585
+ for k, v in response.headers.items()
586
+ if k in RECORDED_HEADERS or (spec.cursor_header and k == spec.cursor_header.lower())
587
+ },
588
+ "body": redacted,
589
+ **({"text": raw_text} if raw_text is not None else {}),
590
+ "page": page + 1,
591
+ }
592
+ encoded = json.dumps(record, sort_keys=True, indent=1, ensure_ascii=False).encode("utf-8")
593
+ record_path = root / CAPTURES / operation.name / f"{stamp}-p{page + 1}.json"
594
+ record_path.parent.mkdir(parents=True, exist_ok=True)
595
+ record_path.write_bytes(encoded)
596
+ _append_manifest(root, record_path, record, hashlib.sha256(encoded).hexdigest())
597
+ written.append(record_path)
598
+ if not (200 <= response.status < 300) or not (spec.cursor_field or spec.cursor_header):
599
+ break
600
+ if spec.cursor_header:
601
+ cursor = response.headers.get(spec.cursor_header.lower())
602
+ else:
603
+ cursor = _dig(body, spec.cursor_field or "")
604
+ if not isinstance(cursor, str) or not cursor:
605
+ break
606
+ return written
607
+
608
+
609
+ def _append_manifest(root: Path, path: Path, record: dict, sha256: str) -> None:
610
+ manifest_path = root / CAPTURES / MANIFEST
611
+ entries = json.loads(manifest_path.read_text()) if manifest_path.is_file() else []
612
+ entries.append(
613
+ {
614
+ "file": path.relative_to(root).as_posix(),
615
+ "operation": record["operation"],
616
+ "status": record["status"],
617
+ "captured_at": record["captured_at"],
618
+ "sha256": sha256,
619
+ }
620
+ )
621
+ manifest_path.write_text(json.dumps(entries, indent=1) + "\n")
622
+
623
+
624
+ # ---------------------------------------------------------------- ingest
625
+
626
+
627
+ def _key(contract: Any, entity: str, row: dict) -> tuple | None:
628
+ fields = contract.entities.get(entity, {}).get("primary_key", [])
629
+ if not fields or any(field not in row for field in fields):
630
+ return None
631
+ return tuple(json.dumps(row[field], sort_keys=True) for field in fields)
632
+
633
+
634
+ def _mutations(root: Path, spec: Spec, contract: Any, prior: list[dict]) -> list[dict]:
635
+ """Insert what is new, update what changed, skip what the world already holds."""
636
+ manifest_path = root / CAPTURES / MANIFEST
637
+ if not manifest_path.is_file():
638
+ fail("ingest", "No captures to ingest; run capture first", f"/{CAPTURES}")
639
+ held: dict[tuple[str, tuple], dict] = {}
640
+ for mutation in prior:
641
+ if mutation.get("op") in ("insert", "update") and isinstance(mutation.get("value"), dict):
642
+ key = _key(contract, mutation["entity"], mutation["value"])
643
+ if key is not None:
644
+ held[(mutation["entity"], key)] = mutation["value"]
645
+ mutations: list[dict] = []
646
+ seen: dict[tuple[str, str], str] = {}
647
+ for entry in json.loads(manifest_path.read_text()):
648
+ if not (200 <= entry["status"] < 300):
649
+ continue
650
+ operation = spec.operations.get(entry["operation"])
651
+ if operation is None or not operation.entity:
652
+ continue
653
+ path = root / entry["file"]
654
+ if not path.is_file():
655
+ fail("ingest", f"Capture listed in the manifest is missing: {entry['file']}", f"/{CAPTURES}")
656
+ encoded = path.read_bytes()
657
+ if hashlib.sha256(encoded).hexdigest() != entry["sha256"]:
658
+ fail("ingest", f"Capture changed since it was recorded: {entry['file']}", f"/{CAPTURES}")
659
+ record = json.loads(encoded)
660
+ rows = _dig(record["body"], operation.results) if operation.results else record["body"]
661
+ if rows is None:
662
+ continue
663
+ if isinstance(rows, dict):
664
+ rows = [rows]
665
+ if not isinstance(rows, list):
666
+ fail("ingest", f"results path {operation.results!r} is not an array in {entry['file']}", f"/{CAPTURES}")
667
+ for raw_row in rows:
668
+ if not isinstance(raw_row, dict):
669
+ fail("ingest", f"A row in {entry['file']} is not an object", f"/{CAPTURES}")
670
+ for entity, row in _project(operation, raw_row, entry["file"]):
671
+ identity = hashlib.sha256(json.dumps(row, sort_keys=True).encode()).hexdigest()
672
+ token = (entity, identity)
673
+ if token in seen:
674
+ continue
675
+ seen[token] = entry["file"]
676
+ key = _key(contract, entity, row)
677
+ existing = held.get((entity, key)) if key is not None else None
678
+ if existing == row:
679
+ continue
680
+ mutation = {
681
+ "op": "update" if existing is not None else "insert",
682
+ "entity": entity,
683
+ "value": row,
684
+ "source": {
685
+ "record": f"{spec.slug}:{operation.name}:{identity}",
686
+ "evidence": [{"reference": f"capture:{entry['file']}", "digest": entry["sha256"]}],
687
+ },
688
+ }
689
+ if existing is not None:
690
+ fields = contract.entities[entity]["primary_key"]
691
+ mutation["key"] = {field: row[field] for field in fields}
692
+ mutations.append(mutation)
693
+ return mutations
694
+
695
+
696
+ def _project(operation: Operation, raw: dict, file: str) -> list[tuple[str, dict]]:
697
+ """The entity rows one vendor row yields: children exploded first, then the parent without its dropped fields."""
698
+ out: list[tuple[str, dict]] = []
699
+ for explode in operation.explode:
700
+ items = raw.get(explode.field)
701
+ if items is None:
702
+ continue
703
+ if not isinstance(items, list):
704
+ fail("ingest", f"{explode.field} is not an array in {file}", f"/{CAPTURES}")
705
+ for item in items:
706
+ if not isinstance(item, dict):
707
+ fail("ingest", f"An item of {explode.field} in {file} is not an object", f"/{CAPTURES}")
708
+ child = {name: raw.get(source) for name, source in explode.carry.items()}
709
+ child.update({name: item.get(source) for name, source in explode.take.items()})
710
+ out.append((explode.entity, child))
711
+ parent = {k: v for k, v in raw.items() if k not in operation.drop}
712
+ assert operation.entity is not None
713
+ out.append((operation.entity, parent))
714
+ return out
715
+
716
+
717
+ def ingest(world: str | Path, *, batch_id: str | None = None) -> dict:
718
+ """Seed the world from its captures, gated by the contract.
719
+
720
+ The existing ``data/initial.json`` mutations are kept and the captured rows
721
+ appended as a new batch version. The batch is applied in a scratch copy of
722
+ the world first; only a batch the store accepts is written back, and the
723
+ world is recompiled and checked so the lock reflects the new state.
724
+ """
725
+ root = Path(world)
726
+ spec = load_spec(root)
727
+ contract = compile_bundle(root)
728
+ initial_path = root / "data/initial.json"
729
+ previous = json.loads(initial_path.read_text()) if initial_path.is_file() else None
730
+ prior_mutations = list(previous["mutations"]) if previous else []
731
+ prior_id = previous["id"] if previous else f"{contract.metadata['package']['id']}.initial.v0"
732
+ version = int(re.search(r"\.v(\d+)$", prior_id).group(1)) + 1 if re.search(r"\.v(\d+)$", prior_id) else 1
733
+ fresh = _mutations(root, spec, contract, prior_mutations)
734
+ if not fresh:
735
+ if not prior_mutations:
736
+ fail(
737
+ "ingest",
738
+ "No successful capture maps to an entity; give the operation an entity and results path",
739
+ f"/{CAPTURES}",
740
+ )
741
+ counts = _counts(root, contract)
742
+ return {"batch": prior_id, "mutations": len(prior_mutations), "entity_counts": counts, "changed": False}
743
+ batch = {
744
+ "id": batch_id or f"{contract.metadata['package']['id']}.initial.v{version}",
745
+ "target": contract.target,
746
+ "mapping": {"id": f"connector-capture:{spec.slug}", "version": "1"},
747
+ "coverage": "partial",
748
+ "mutations": prior_mutations + fresh,
749
+ }
750
+ from .._materialize import materialize_template # only ingest needs the parent package
751
+
752
+ with tempfile.TemporaryDirectory(prefix="gateway-ingest-") as scratch:
753
+ staged = Path(scratch) / "world"
754
+ shutil.copytree(
755
+ root, staged, ignore=shutil.ignore_patterns(CAPTURES, "__pycache__", "state.sqlite", "state.json")
756
+ )
757
+ (staged / "data/initial.json").write_text(json.dumps(batch, sort_keys=True, indent=1) + "\n")
758
+ compile_bundle(staged)
759
+ database = Path(scratch) / "world.db"
760
+ materialize_template(staged, database)
761
+ with SQLiteStore.open(database, check_bundle(staged)) as store:
762
+ store.audit()
763
+ counts = {entity: len(store.list(entity)) for entity in contract.entities}
764
+ initial_path.write_text(json.dumps(batch, sort_keys=True, indent=1) + "\n")
765
+ compile_bundle(root)
766
+ check_bundle(root)
767
+ return {"batch": batch["id"], "mutations": len(batch["mutations"]), "entity_counts": counts, "changed": True}
768
+
769
+
770
+ def _counts(root: Path, contract: Any) -> dict[str, int]:
771
+ from .._materialize import materialize_template
772
+
773
+ with tempfile.TemporaryDirectory(prefix="gateway-ingest-") as scratch:
774
+ database = Path(scratch) / "world.db"
775
+ materialize_template(root, database)
776
+ with SQLiteStore.open(database, contract) as store:
777
+ return {entity: len(store.list(entity)) for entity in contract.entities}