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,992 @@
1
+ """Gateway benchmark-container push client.
2
+
3
+ Drives the Gateway hub push flow end-to-end — the reimplemented (not Prime's
4
+ CLI-welded) client, talking only to a Gateway host, never to Prime:
5
+
6
+ resolve (get-or-create container)
7
+ -> versions (create/dedup at content hash, get a presigned PUT)
8
+ -> PUT the source tarball (with the real object sha256 as the S3 checksum)
9
+ -> finalize (mark the version READY)
10
+
11
+ Stdlib only (urllib/hashlib/base64/tarfile/tomllib) — no extra dependencies.
12
+
13
+ The source content hash is byte-compatible with Prime's hub (vendored helper).
14
+ The *tarball* object sha256 is computed separately and sent as the presign
15
+ checksum (base64) — distinct from the content hash — which is what satisfies the
16
+ x-amz-checksum-sha256 the Gateway presign bakes into the signature.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import gzip
23
+ import hashlib
24
+ import io
25
+ import json
26
+ import os
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ import tarfile
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+ import zipfile
35
+ from email.parser import Parser
36
+ from pathlib import Path
37
+ from typing import Any, Dict, List, Optional
38
+ from urllib.parse import urlencode
39
+
40
+ try:
41
+ import tomllib
42
+ except ModuleNotFoundError:
43
+ import tomli as tomllib
44
+
45
+ from ..environment._materialize import validate_schema_materialization
46
+ from ..environment.schema.compiler import bundle_mode
47
+ from ..environment.schema.validation import ContractError
48
+ from ._archive import collect_archive_files, compute_content_hash
49
+ from ._tar import safe_tar_extract
50
+ from .versioning import (
51
+ bump_post_version,
52
+ bump_rc_version,
53
+ bump_version,
54
+ update_pyproject_version,
55
+ )
56
+
57
+ # ── Local working-copy metadata (.gateway/.env-metadata.json) ────────────────
58
+ # The dir's link back to the hub: which container it came from, which VERSION
59
+ # it was based on, and which BRANCH it tracks. `pull`/`checkout` write it,
60
+ # `push` reads it (the fast-forward base) and rewrites it (your own push
61
+ # becomes the new base) — the same role .git/HEAD plays for git.
62
+
63
+ DEFAULT_BRANCH = "main"
64
+ _META_RELPATH = Path(".gateway") / ".env-metadata.json"
65
+
66
+
67
+ def read_local_meta(env_path: Path) -> Optional[Dict[str, Any]]:
68
+ """The dir's recorded base/branch, or None (an ad-hoc dir)."""
69
+ meta_path = Path(env_path) / _META_RELPATH
70
+ if not meta_path.is_file():
71
+ return None
72
+ try:
73
+ loaded = json.loads(meta_path.read_text())
74
+ return loaded if isinstance(loaded, dict) else None
75
+ except (OSError, json.JSONDecodeError):
76
+ return None
77
+
78
+
79
+ def snapshot_tree_hashes(env_path: Path) -> Dict[str, str]:
80
+ """rel path -> sha256 over the ARCHIVE-VISIBLE tree (collect_archive_files
81
+ rules). This is the working copy's base-file record: diffing against the
82
+ server manifest instead would surface phantom deletes, because the browser
83
+ manifest deliberately lists files (dotfiles, gitignored paths) that the
84
+ source tarball never contains."""
85
+ out: Dict[str, str] = {}
86
+ for file_path in collect_archive_files(env_path):
87
+ rel = str(file_path.relative_to(env_path)).replace("\\", "/")
88
+ out[rel] = hashlib.sha256(file_path.read_bytes()).hexdigest()
89
+ return out
90
+
91
+
92
+ def local_tree_state(env_path: Path) -> Dict[str, Any]:
93
+ """The dir's hub link plus whether its ARCHIVE-VISIBLE tree still matches
94
+ the recorded base: ``{"meta", "dirty", "clean"}``. ``meta`` is None for an
95
+ ad-hoc dir (never pulled/pushed); ``dirty`` lists rel paths that changed,
96
+ appeared, or vanished vs the base snapshot; ``clean`` is True only when a
97
+ base exists AND nothing drifted — the one state where a local run can be
98
+ honestly pinned to the recorded version."""
99
+ meta = read_local_meta(Path(env_path))
100
+ base = meta.get("baseFiles") if meta else None
101
+ if not isinstance(base, dict):
102
+ return {"meta": meta, "dirty": [], "clean": False}
103
+ now = snapshot_tree_hashes(Path(env_path))
104
+ dirty = sorted({p for p, h in now.items() if base.get(p) != h} | {p for p in base if p not in now})
105
+ return {"meta": meta, "dirty": dirty, "clean": not dirty}
106
+
107
+
108
+ def write_local_meta(env_path: Path, meta: Dict[str, Any]) -> None:
109
+ meta_dir = Path(env_path) / _META_RELPATH.parent
110
+ meta_dir.mkdir(exist_ok=True)
111
+ (Path(env_path) / _META_RELPATH).write_text(json.dumps(meta, indent=2))
112
+
113
+
114
+ # Includes the build-artifact dirs the ARCHIVE collector prunes (_archive.py) —
115
+ # otherwise `push --build-wheel` writes dist/<name>.whl BEFORE the manifest is
116
+ # collected and the browser lists a file the source bundle cannot serve.
117
+ _SKIP_DIRS = {
118
+ ".git",
119
+ "__pycache__",
120
+ "node_modules",
121
+ ".venv",
122
+ ".mypy_cache",
123
+ "dist",
124
+ "build",
125
+ "outputs",
126
+ }
127
+
128
+
129
+ class BenchmarkPushError(RuntimeError):
130
+ """Raised when any step of the push flow fails."""
131
+
132
+
133
+ def read_pyproject(env_path: Path) -> Dict[str, Any]:
134
+ """Best-effort name/version/deps from pyproject.toml (empty dict if absent)."""
135
+ env_path = Path(env_path)
136
+ pp = env_path / "pyproject.toml"
137
+ if not pp.exists():
138
+ return {}
139
+ try:
140
+ data = tomllib.loads(pp.read_text())
141
+ except Exception:
142
+ return {}
143
+ project = data.get("project", {}) if isinstance(data, dict) else {}
144
+ meta: Dict[str, Any] = {}
145
+ for key, out in (
146
+ ("name", "name"),
147
+ ("version", "version"),
148
+ ("dependencies", "dependencies"),
149
+ ("requires-python", "requires_python"),
150
+ ):
151
+ if key in project:
152
+ meta[out] = project[key]
153
+ return meta
154
+
155
+
156
+ def build_source_tarball(env_path: Path) -> bytes:
157
+ """A deterministic .tar.gz of the SOURCE files (the same set the content hash
158
+ covers), with cleared mtime/owner so identical source yields a stable bundle."""
159
+ env_path = Path(env_path)
160
+ files = sorted(
161
+ collect_archive_files(env_path),
162
+ key=lambda p: str(Path(p).relative_to(env_path)).replace("\\", "/"),
163
+ )
164
+ buf = io.BytesIO()
165
+ # Wrap the tar in a gzip stream with mtime=0 (tarfile's own "w:gz" stamps the
166
+ # current time into the gzip header) + clear per-entry mtime/owner, so
167
+ # identical source yields identical bytes.
168
+ gz = gzip.GzipFile(fileobj=buf, mode="wb", mtime=0, compresslevel=9)
169
+ try:
170
+ with tarfile.open(fileobj=gz, mode="w:") as tar:
171
+ for f in files:
172
+ rel = str(Path(f).relative_to(env_path)).replace("\\", "/")
173
+ payload = Path(f).read_bytes()
174
+ info = tarfile.TarInfo(name=rel)
175
+ info.size = len(payload)
176
+ info.mtime = 0
177
+ info.uid = info.gid = 0
178
+ info.uname = info.gname = ""
179
+ info.mode = 0o644
180
+ tar.addfile(info, io.BytesIO(payload))
181
+ finally:
182
+ gz.close()
183
+ return buf.getvalue()
184
+
185
+
186
+ def build_file_manifest(env_path: Path) -> List[Dict[str, Any]]:
187
+ """The FULL file tree (configs, data, .github — not just source), decoupled
188
+ from the source-only content hash, to power the GitHub-style browser."""
189
+ env_path = Path(env_path)
190
+ entries: List[Dict[str, Any]] = []
191
+ for root, dirs, filenames in os.walk(env_path):
192
+ dirs[:] = sorted(d for d in dirs if d not in _SKIP_DIRS and not d.endswith(".egg-info"))
193
+ for fn in sorted(filenames):
194
+ p = Path(root) / fn
195
+ try:
196
+ payload = p.read_bytes()
197
+ except OSError:
198
+ continue
199
+ entries.append(
200
+ {
201
+ "path": str(p.relative_to(env_path)).replace("\\", "/"),
202
+ "isDirectory": False,
203
+ "size": len(payload),
204
+ "sha256": hashlib.sha256(payload).hexdigest(),
205
+ }
206
+ )
207
+ return entries
208
+
209
+
210
+ def sha256_base64(data: bytes) -> str:
211
+ """Base64 of the raw sha256 digest — the format S3/MinIO want for
212
+ x-amz-checksum-sha256 (NOT hex)."""
213
+ return base64.b64encode(hashlib.sha256(data).digest()).decode("ascii")
214
+
215
+
216
+ def normalize_package_name(name: str) -> str:
217
+ """PyPI-normalized module/dist name (the pip target uses underscores)."""
218
+ return name.replace("-", "_").lower()
219
+
220
+
221
+ def build_wheel(env_path: Path) -> Path:
222
+ """Build a wheel from the env dir via ``uv build --wheel`` (fallback
223
+ ``python -m build --wheel``). Cleans dist/ first; returns the wheel path."""
224
+ env_path = Path(env_path)
225
+ dist = env_path / "dist"
226
+ if dist.exists():
227
+ shutil.rmtree(dist)
228
+ try:
229
+ if shutil.which("uv"):
230
+ subprocess.run(
231
+ ["uv", "build", "--wheel", "--out-dir", "dist"],
232
+ cwd=env_path,
233
+ check=True,
234
+ capture_output=True,
235
+ text=True,
236
+ )
237
+ else:
238
+ subprocess.run(
239
+ [sys.executable, "-m", "build", "--wheel", str(env_path)],
240
+ check=True,
241
+ capture_output=True,
242
+ text=True,
243
+ )
244
+ except subprocess.CalledProcessError as e:
245
+ raise BenchmarkPushError(f"wheel build failed: {(e.stderr or e.stdout or str(e))[:500]}") from None
246
+ wheels = sorted(dist.glob("*.whl"))
247
+ if not wheels:
248
+ raise BenchmarkPushError("wheel build produced no .whl")
249
+ return wheels[-1]
250
+
251
+
252
+ def parse_wheel_requires_dist(wheel_path: Path) -> List[str]:
253
+ """The ``Requires-Dist`` lines from a wheel's ``*.dist-info/METADATA``."""
254
+ with zipfile.ZipFile(wheel_path) as zf:
255
+ metas = [n for n in zf.namelist() if n.endswith(".dist-info/METADATA")]
256
+ if not metas:
257
+ return []
258
+ text = zf.read(metas[0]).decode("utf-8", "replace")
259
+ return Parser().parsestr(text).get_all("Requires-Dist") or []
260
+
261
+
262
+ class GatewayBenchmarkClient:
263
+ """Talks to a Gateway host's benchmark-container public API. Writes require a
264
+ secret key, so authenticate with the project's public + secret key (Basic)."""
265
+
266
+ def __init__(self, host: str, public_key: str, secret_key: str) -> None:
267
+ if not host:
268
+ raise ValueError("host is required (e.g. https://withgateway.ai)")
269
+ if not public_key or not secret_key:
270
+ raise ValueError("both public_key and secret_key are required")
271
+ self.host = host.rstrip("/")
272
+ self._auth = base64.b64encode(f"{public_key}:{secret_key}".encode("utf-8")).decode("ascii")
273
+
274
+ @classmethod
275
+ def from_env(cls, host: Optional[str] = None) -> "GatewayBenchmarkClient":
276
+ return cls(
277
+ host=host or os.environ.get("GATEWAY_HOST", ""),
278
+ public_key=os.environ.get("GATEWAY_PUBLIC_KEY", ""),
279
+ secret_key=os.environ.get("GATEWAY_SECRET_KEY", ""),
280
+ )
281
+
282
+ def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
283
+ req = urllib.request.Request(
284
+ f"{self.host}{path}",
285
+ data=json.dumps(body).encode("utf-8"),
286
+ method="POST",
287
+ headers={
288
+ "Authorization": f"Basic {self._auth}",
289
+ "Content-Type": "application/json",
290
+ },
291
+ )
292
+ try:
293
+ with urllib.request.urlopen(req, timeout=120) as resp:
294
+ return json.loads(resp.read().decode("utf-8"))
295
+ except urllib.error.HTTPError as e:
296
+ detail = e.read().decode("utf-8", "replace")[:500]
297
+ raise BenchmarkPushError(f"POST {path} -> {e.code}: {detail}") from None
298
+
299
+ def _get(self, path: str) -> Dict[str, Any]:
300
+ req = urllib.request.Request(
301
+ f"{self.host}{path}",
302
+ method="GET",
303
+ headers={"Authorization": f"Basic {self._auth}"},
304
+ )
305
+ try:
306
+ with urllib.request.urlopen(req, timeout=120) as resp:
307
+ return json.loads(resp.read().decode("utf-8"))
308
+ except urllib.error.HTTPError as e:
309
+ detail = e.read().decode("utf-8", "replace")[:500]
310
+ raise BenchmarkPushError(f"GET {path} -> {e.code}: {detail}") from None
311
+
312
+ def _put_object(
313
+ self,
314
+ url: str,
315
+ data: bytes,
316
+ sha256_b64: str,
317
+ content_type: str = "application/gzip",
318
+ ) -> None:
319
+ req = urllib.request.Request(
320
+ url,
321
+ data=data,
322
+ method="PUT",
323
+ headers={
324
+ "Content-Type": content_type,
325
+ "x-amz-checksum-sha256": sha256_b64,
326
+ },
327
+ )
328
+ try:
329
+ with urllib.request.urlopen(req, timeout=300) as resp:
330
+ resp.read()
331
+ except urllib.error.HTTPError as e:
332
+ detail = e.read().decode("utf-8", "replace")[:500]
333
+ raise BenchmarkPushError(f"object PUT -> {e.code}: {detail}") from None
334
+
335
+ def push(
336
+ self,
337
+ env_path: str | Path,
338
+ *,
339
+ slug: Optional[str] = None,
340
+ name: Optional[str] = None,
341
+ description: Optional[str] = None,
342
+ tags: Optional[List[str]] = None,
343
+ visibility: Optional[str] = None,
344
+ auto_bump: bool = False,
345
+ rc: bool = False,
346
+ post: bool = False,
347
+ image_ref: Optional[str] = None,
348
+ wheel: bool = False,
349
+ branch: Optional[str] = None,
350
+ force: bool = False,
351
+ message: Optional[str] = None,
352
+ author: Optional[str] = None,
353
+ ) -> Dict[str, Any]:
354
+ env_path = Path(env_path)
355
+ if not env_path.is_dir():
356
+ raise BenchmarkPushError(f"Not a directory: {env_path}")
357
+
358
+ # Working-copy state: the base version this dir was pulled from and the
359
+ # branch it tracks. The server compare-and-sets the branch tip against
360
+ # the base at finalize — a stale base gets a 409 ("branch has moved"),
361
+ # never a silent overwrite. An ad-hoc dir (no metadata) declares
362
+ # base=None; `force` skips the check entirely.
363
+ local_meta = read_local_meta(env_path) or {}
364
+ push_branch = branch or local_meta.get("branch") or DEFAULT_BRANCH
365
+ base_version_id = local_meta.get("versionId")
366
+
367
+ # Optional semver bump (Prime `env push` parity): rewrite pyproject.toml's
368
+ # version BEFORE hashing/tarring so the bump lands in both the bundle and
369
+ # the semanticVersion sent to the hub. Mutates the working tree, like Prime.
370
+ if sum(bool(x) for x in (auto_bump, rc, post)) > 1:
371
+ raise BenchmarkPushError("--auto-bump, --rc and --post are mutually exclusive")
372
+ if auto_bump or rc or post:
373
+ current = read_pyproject(env_path).get("version")
374
+ if not current:
375
+ raise BenchmarkPushError("no [project].version in pyproject.toml to bump")
376
+ new_version = (
377
+ bump_version(current) if auto_bump else bump_rc_version(current) if rc else bump_post_version(current)
378
+ )
379
+ update_pyproject_version(env_path / "pyproject.toml", new_version)
380
+
381
+ try:
382
+ if bundle_mode(env_path) is not None:
383
+ validate_schema_materialization(env_path)
384
+ except ContractError as error:
385
+ raise BenchmarkPushError(f"Schema world check failed before push: {error}") from None
386
+
387
+ content_hash = compute_content_hash(env_path)
388
+ pyproject = read_pyproject(env_path)
389
+ slug = slug or pyproject.get("name") or env_path.name
390
+
391
+ # Optional wheel build (pip-install-by-name). Build BEFORE the versions POST
392
+ # so its object sha256 + Requires-Dist travel with the version request and we
393
+ # get a second presigned PUT back.
394
+ wheel_bytes: Optional[bytes] = None
395
+ wheel_sha256: Optional[str] = None
396
+ wheel_filename: Optional[str] = None
397
+ requires_dist: List[str] = []
398
+ if wheel:
399
+ wheel_path = build_wheel(env_path)
400
+ wheel_bytes = wheel_path.read_bytes()
401
+ wheel_sha256 = sha256_base64(wheel_bytes)
402
+ wheel_filename = wheel_path.name
403
+ requires_dist = parse_wheel_requires_dist(wheel_path)
404
+
405
+ resolve = self._post(
406
+ "/api/public/benchmark-containers/resolve",
407
+ {
408
+ "slug": slug,
409
+ "name": name or pyproject.get("name") or slug,
410
+ **({"description": description} if description else {}),
411
+ **({"tags": tags} if tags else {}),
412
+ **({"visibility": visibility} if visibility else {}),
413
+ },
414
+ )
415
+ container_id = resolve["containerId"]
416
+
417
+ tarball = build_source_tarball(env_path)
418
+ bundle_sha256 = sha256_base64(tarball)
419
+
420
+ version = self._post(
421
+ "/api/public/benchmark-containers/versions",
422
+ {
423
+ "containerId": container_id,
424
+ "contentHash": content_hash,
425
+ "bundleSha256": bundle_sha256,
426
+ "bundleSizeBytes": len(tarball),
427
+ **({"semanticVersion": pyproject["version"]} if pyproject.get("version") else {}),
428
+ "pyprojectMeta": pyproject,
429
+ "fileManifest": build_file_manifest(env_path),
430
+ "branch": push_branch,
431
+ "baseVersionId": base_version_id,
432
+ **({"force": True} if force else {}),
433
+ **({"changeReason": message} if message else {}),
434
+ **({"author": author} if author else {}),
435
+ **({"imageRef": image_ref} if image_ref else {}),
436
+ **(
437
+ {
438
+ "wheelSha256": wheel_sha256,
439
+ "wheelSizeBytes": len(wheel_bytes),
440
+ "wheelFilename": wheel_filename,
441
+ "requiresDist": requires_dist,
442
+ }
443
+ if wheel_bytes is not None and wheel_sha256
444
+ else {}
445
+ ),
446
+ },
447
+ )
448
+ version_id = version["versionId"]
449
+
450
+ if version.get("uploadUrl"):
451
+ self._put_object(version["uploadUrl"], tarball, bundle_sha256)
452
+ if version.get("wheelUploadUrl") and wheel_bytes is not None and wheel_sha256:
453
+ self._put_object(
454
+ version["wheelUploadUrl"],
455
+ wheel_bytes,
456
+ wheel_sha256,
457
+ content_type="application/octet-stream",
458
+ )
459
+
460
+ final = self._post(
461
+ f"/api/public/benchmark-containers/versions/{version_id}/finalize",
462
+ {
463
+ "branch": push_branch,
464
+ "baseVersionId": base_version_id,
465
+ **({"force": True} if force else {}),
466
+ },
467
+ )
468
+
469
+ # The push is the dir's new base — rewrite the metadata so the NEXT
470
+ # push fast-forwards instead of conflicting with your own work.
471
+ write_local_meta(
472
+ env_path,
473
+ {
474
+ "slug": slug,
475
+ "ref": push_branch,
476
+ "branch": push_branch,
477
+ "versionId": version_id,
478
+ "contentHash": content_hash,
479
+ "semanticVersion": pyproject.get("version"),
480
+ "forkedFromVersionId": base_version_id,
481
+ "baseFiles": snapshot_tree_hashes(env_path),
482
+ },
483
+ )
484
+
485
+ return {
486
+ "containerId": container_id,
487
+ "versionId": version_id,
488
+ "slug": slug,
489
+ "contentHash": content_hash,
490
+ "state": final.get("state"),
491
+ "branch": final.get("branch", push_branch),
492
+ "alreadyUploaded": bool(version.get("alreadyUploaded")),
493
+ "wheelBuilt": wheel_bytes is not None,
494
+ }
495
+
496
+ def dispatch_run_config(
497
+ self,
498
+ run_config_id: str,
499
+ *,
500
+ container_id: Optional[str] = None,
501
+ version_id: Optional[str] = None,
502
+ ) -> Dict[str, Any]:
503
+ """Start a hosted run of a run config — the same dispatch the Run
504
+ button uses, parallelism included (the config's `parallelism` fans the
505
+ task list across that many containers per model). Returns
506
+ {versionLabel, runs: [{model, runId, ok, error?}]}."""
507
+ return self._post(
508
+ "/api/public/benchmark-containers/run-configs/dispatch",
509
+ {
510
+ "runConfigId": run_config_id,
511
+ **({"containerId": container_id} if container_id else {}),
512
+ **({"versionId": version_id} if version_id else {}),
513
+ },
514
+ )
515
+
516
+ def _json_request(self, method: str, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
517
+ req = urllib.request.Request(
518
+ f"{self.host}{path}",
519
+ data=json.dumps(body).encode("utf-8"),
520
+ method=method,
521
+ headers={
522
+ "Authorization": f"Basic {self._auth}",
523
+ "Content-Type": "application/json",
524
+ },
525
+ )
526
+ try:
527
+ with urllib.request.urlopen(req, timeout=120) as resp:
528
+ return json.loads(resp.read().decode("utf-8"))
529
+ except urllib.error.HTTPError as e:
530
+ detail = e.read().decode("utf-8", "replace")[:500]
531
+ raise BenchmarkPushError(f"{method} {path} -> {e.code}: {detail}") from None
532
+
533
+ def list_run_configs(self) -> Dict[str, Any]:
534
+ """Run configs in the project — id, name, models, parallelism. The id
535
+ feeds dispatch_run_config; the name is how CI resolves it."""
536
+ return self._get("/api/public/benchmark-containers/run-configs")
537
+
538
+ def upsert_run_config(self, **fields: Any) -> Dict[str, Any]:
539
+ """Create a run config (or update, when ``id`` is given). Fields match
540
+ the Configs form: name, models, parallelism, numExamples,
541
+ rolloutsPerExample, timeoutMinutes, agent, taskSetDatasetId,
542
+ taskNames, worldTasks, autoRunOnPush. A config runs EITHER the bundle's
543
+ own scenarios (``taskNames`` / ``taskSetDatasetId``) OR the project's
544
+ stored tasks (``worldTasks``: ``[{"id": ..., "version"?: ...}]``, api
545
+ worlds only) — never both."""
546
+ return self._json_request("POST", "/api/public/benchmark-containers/run-configs", fields)
547
+
548
+ def delete_run_config(self, config_id: str) -> Dict[str, Any]:
549
+ return self._json_request(
550
+ "DELETE",
551
+ "/api/public/benchmark-containers/run-configs",
552
+ {"id": config_id},
553
+ )
554
+
555
+ def set_env_secret(self, slug: str, key: str, value: str) -> Dict[str, Any]:
556
+ """Set (or rotate) one environment secret — an env var every hosted
557
+ run of the world receives. Write-only: no API returns the value."""
558
+ return self._json_request(
559
+ "PUT",
560
+ "/api/public/benchmark-containers/secrets",
561
+ {"slug": slug, "key": key, "value": value},
562
+ )
563
+
564
+ def list_env_secret_keys(self, slug: str) -> Dict[str, Any]:
565
+ """Keys and rotation timestamps only — never values."""
566
+ return self._get(f"/api/public/benchmark-containers/secrets?slug={urllib.parse.quote(slug)}")
567
+
568
+ def delete_env_secret(self, slug: str, key: str) -> Dict[str, Any]:
569
+ return self._json_request(
570
+ "DELETE",
571
+ "/api/public/benchmark-containers/secrets",
572
+ {"slug": slug, "key": key},
573
+ )
574
+
575
+ def resolve_version(self, slug: str, ref: str = "latest") -> Dict[str, Any]:
576
+ """Resolve a container by slug + ref ('latest' | semver | contentHash
577
+ prefix) to a concrete READY version + a signed source-bundle URL."""
578
+ return self._post(
579
+ "/api/public/benchmark-containers/resolve-version",
580
+ {"slug": slug, **({"ref": ref} if ref else {})},
581
+ )
582
+
583
+ def pull(self, slug_ref: str, target: Optional[str] = None) -> Dict[str, Any]:
584
+ """Reconstruct a working dir from a version, addressed by name.
585
+
586
+ `slug_ref` is ``slug`` or ``slug@latest|@<semver>|@<hash-prefix>``. When
587
+ `target` is omitted the dir is `./<slug>`, de-duplicated with a ``-1``/``-2``
588
+ suffix (Prime `env pull` behavior). Writes `.gateway/.env-metadata.json`
589
+ (a dotdir, excluded from the content hash) so pull→edit→re-push is stable.
590
+ """
591
+ if "@" in slug_ref:
592
+ slug, ref = slug_ref.rsplit("@", 1)
593
+ else:
594
+ slug, ref = slug_ref, "latest"
595
+
596
+ details = self.resolve_version(slug, ref)
597
+ url = details.get("downloadUrl")
598
+ if not url:
599
+ raise BenchmarkPushError(f"{slug}@{ref} resolved but has no uploaded bundle")
600
+
601
+ base = Path(target) if target else Path.cwd() / slug
602
+ dst = base
603
+ if target is None and dst.exists():
604
+ i = 1
605
+ while dst.exists():
606
+ dst = base.parent / f"{base.name}-{i}"
607
+ i += 1
608
+ dst.mkdir(parents=True, exist_ok=True)
609
+
610
+ tmp = dst / ".bundle.tar.gz"
611
+ try: # signed URL — no auth header
612
+ with urllib.request.urlopen(urllib.request.Request(url), timeout=300) as r:
613
+ bundle_bytes = r.read()
614
+ except urllib.error.HTTPError as e:
615
+ detail = e.read().decode("utf-8", "replace")[:300]
616
+ raise BenchmarkPushError(f"bundle download -> {e.code}: {detail}") from None
617
+
618
+ # Verify the downloaded bytes against the version's recorded object
619
+ # sha256, so a divergent bundle (overwritten object, corrupted transfer)
620
+ # fails loudly instead of silently extracting different content under a
621
+ # pinned "immutable" version. Older servers omit the field -> skip.
622
+ expected_sha = details.get("bundleSha256")
623
+ if expected_sha:
624
+ actual_sha = base64.b64encode(hashlib.sha256(bundle_bytes).digest()).decode()
625
+ if actual_sha != expected_sha:
626
+ raise BenchmarkPushError(
627
+ f"bundle integrity check failed for {slug}@{ref}: downloaded "
628
+ f"object sha256 ({actual_sha}) != recorded bundleSha256 "
629
+ f"({expected_sha})"
630
+ )
631
+ tmp.write_bytes(bundle_bytes)
632
+
633
+ with tarfile.open(tmp, "r:gz") as tar:
634
+ safe_tar_extract(tar, dst)
635
+ tmp.unlink(missing_ok=True)
636
+
637
+ # Track the branch that answered the ref; a semver/hash pin is a
638
+ # detached checkout, which pushes onto main unless told otherwise.
639
+ branch = details.get("branch") or (DEFAULT_BRANCH if ref == "latest" else None)
640
+ write_local_meta(
641
+ dst,
642
+ {
643
+ "slug": slug,
644
+ "ref": ref,
645
+ "branch": branch or DEFAULT_BRANCH,
646
+ "versionId": details.get("versionId"),
647
+ "contentHash": details.get("contentHash"),
648
+ "semanticVersion": details.get("semanticVersion"),
649
+ "forkedFromVersionId": details.get("forkedFromVersionId"),
650
+ "baseFiles": snapshot_tree_hashes(dst),
651
+ },
652
+ )
653
+ return {
654
+ "slug": slug,
655
+ "ref": ref,
656
+ "branch": branch or DEFAULT_BRANCH,
657
+ "versionId": details.get("versionId"),
658
+ "contentHash": details.get("contentHash"),
659
+ "target": str(dst),
660
+ }
661
+
662
+ # ── Refs (branches + tags) ───────────────────────────────────────────
663
+
664
+ def list_refs(self, slug: str) -> Dict[str, Any]:
665
+ """Every branch and tag of a container with tip version info."""
666
+ qs = urlencode({"slug": slug})
667
+ return self._get(f"/api/public/benchmark-containers/refs?{qs}")
668
+
669
+ def create_ref(
670
+ self,
671
+ slug: str,
672
+ name: str,
673
+ *,
674
+ kind: str = "branch",
675
+ version_id: Optional[str] = None,
676
+ ) -> Dict[str, Any]:
677
+ """Create a branch (movable, starts at the main tip unless pinned) or
678
+ an immutable tag (points at a concrete version forever)."""
679
+ return self._post(
680
+ "/api/public/benchmark-containers/refs",
681
+ {
682
+ "slug": slug,
683
+ "name": name,
684
+ "kind": kind,
685
+ **({"versionId": version_id} if version_id else {}),
686
+ },
687
+ )
688
+
689
+ def log(self, slug: str, *, branch: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
690
+ """A branch's version lineage, tip first (`bench log`)."""
691
+ qs = urlencode({"slug": slug, "limit": str(limit), **({"branch": branch} if branch else {})})
692
+ return self._get(f"/api/public/benchmark-containers/log?{qs}")
693
+
694
+ def status(self, env_path: str | Path) -> Dict[str, Any]:
695
+ """The working dir's position: branch, base, dirtiness, and whether
696
+ the remote branch tip has moved past the base (`bench status`)."""
697
+ env_path = Path(env_path)
698
+ meta = read_local_meta(env_path)
699
+ if not meta or not meta.get("slug"):
700
+ raise BenchmarkPushError(
701
+ f"{env_path} has no .gateway/.env-metadata.json — pull or push it "
702
+ "first so the dir knows which container/branch it tracks."
703
+ )
704
+ slug = meta["slug"]
705
+ branch = meta.get("branch") or DEFAULT_BRANCH
706
+ local_hash = compute_content_hash(env_path)
707
+ dirty = local_hash != meta.get("contentHash")
708
+
709
+ refs = self.list_refs(slug).get("refs", [])
710
+ tip = next((r for r in refs if r.get("name") == branch), None)
711
+ behind = bool(tip and tip.get("versionId") != meta.get("versionId"))
712
+ return {
713
+ "slug": slug,
714
+ "branch": branch,
715
+ "baseVersionId": meta.get("versionId"),
716
+ "baseContentHash": meta.get("contentHash"),
717
+ "localContentHash": local_hash,
718
+ "dirty": dirty,
719
+ "behind": behind,
720
+ "remoteTipVersionId": tip.get("versionId") if tip else None,
721
+ "remoteTipContentHash": tip.get("contentHash") if tip else None,
722
+ }
723
+
724
+ def propose(
725
+ self,
726
+ env_path: str | Path,
727
+ *,
728
+ title: str,
729
+ message: str,
730
+ target_branch: Optional[str] = None,
731
+ ) -> Dict[str, Any]:
732
+ """File the working dir's local edits as a change proposal ("PR to the
733
+ env") instead of pushing straight to the branch. The changeset is the
734
+ diff between the dir and its recorded base version's file manifest;
735
+ review + apply live on the environment homepage's Changes tab.
736
+
737
+ A dir sitting on a pushed BRANCH proposing into a different target is
738
+ a branch-backed PR (GitHub semantics): the diff is computed against
739
+ the TARGET branch's tip — diffing against the dir's own base right
740
+ after a push is empty by construction — and the branch is recorded
741
+ so runs of the proposal dispatch the branch tip directly."""
742
+ env_path = Path(env_path)
743
+ meta = read_local_meta(env_path)
744
+ if not meta or not meta.get("slug") or not meta.get("versionId"):
745
+ raise BenchmarkPushError(
746
+ f"{env_path} has no recorded base (.gateway/.env-metadata.json) — "
747
+ "a proposal is a diff against a base version, pull first."
748
+ )
749
+ slug = meta["slug"]
750
+ base_version_id = meta["versionId"]
751
+ recorded_branch = meta.get("branch") or meta.get("ref")
752
+ target = target_branch or DEFAULT_BRANCH
753
+
754
+ def _manifest_hashes(version_id: str) -> Dict[str, Any]:
755
+ # Server manifest as a base-file record. Dot-paths are dropped —
756
+ # the manifest deliberately lists files the source tarball never
757
+ # contained, which would read as phantom deletes here.
758
+ listed = self._get(f"/api/public/benchmark-containers/versions/{version_id}/files").get("files", [])
759
+ return {
760
+ f["path"]: f.get("sha256")
761
+ for f in listed
762
+ if not f.get("isDirectory") and not any(seg.startswith(".") for seg in f["path"].split("/"))
763
+ }
764
+
765
+ source_branch: Optional[str] = None
766
+ if recorded_branch and recorded_branch != target:
767
+ source_branch = str(recorded_branch)
768
+ try:
769
+ tip = self.resolve_version(slug, target)
770
+ except Exception as exc:
771
+ raise BenchmarkPushError(
772
+ f"could not resolve target branch '{target}' to propose " f"against: {exc}"
773
+ ) from None
774
+ base_version_id = tip["versionId"]
775
+ base_by_path: Dict[str, Any] = _manifest_hashes(base_version_id)
776
+ else:
777
+ # Same-branch proposal: the tarball tree snapshotted at pull/push
778
+ # time; the manifest is only a pre-baseFiles fallback.
779
+ base_by_path = meta.get("baseFiles") or {}
780
+ if not base_by_path:
781
+ base_by_path = _manifest_hashes(base_version_id)
782
+
783
+ changes: List[Dict[str, Any]] = []
784
+ local_paths = set()
785
+ for file_path in collect_archive_files(env_path):
786
+ rel = str(file_path.relative_to(env_path)).replace("\\", "/")
787
+ local_paths.add(rel)
788
+ data = file_path.read_bytes()
789
+ digest = hashlib.sha256(data).hexdigest()
790
+ if base_by_path.get(rel) == digest:
791
+ continue
792
+ try:
793
+ text = data.decode("utf-8")
794
+ except UnicodeDecodeError:
795
+ raise BenchmarkPushError(
796
+ f"{rel} is not utf-8 text — proposals carry text changesets; "
797
+ "push binary changes directly to a branch instead."
798
+ ) from None
799
+ changes.append({"path": rel, "action": "write", "newContent": text})
800
+ for rel in sorted(set(base_by_path) - local_paths):
801
+ changes.append({"path": rel, "action": "delete"})
802
+
803
+ if not changes:
804
+ raise BenchmarkPushError(
805
+ f"No changes against {target} — nothing to propose."
806
+ + (f" (branch {source_branch} matches the target's tip)" if source_branch else "")
807
+ )
808
+
809
+ result = self._post(
810
+ "/api/public/benchmark-containers/proposals",
811
+ {
812
+ "slug": slug,
813
+ "title": title,
814
+ "changeReason": message,
815
+ "baseVersionId": base_version_id,
816
+ **({"targetBranch": target_branch} if target_branch else {}),
817
+ **({"sourceBranch": source_branch} if source_branch else {}),
818
+ "changes": changes,
819
+ },
820
+ )
821
+ return result
822
+
823
+ def fork(self, slug: str, target_slug: Optional[str] = None) -> Dict[str, Any]:
824
+ """Fork a container: create a new container whose head version copies the
825
+ source's latest READY version (shared bundle, recorded provenance). Forks
826
+ are container-level — pass a bare slug, not ``slug@version``."""
827
+ if "@" in slug:
828
+ raise BenchmarkPushError("fork a container head; omit the @version suffix")
829
+ return self._post(
830
+ "/api/public/benchmark-containers/fork",
831
+ {
832
+ "slug": slug,
833
+ **({"targetSlug": target_slug} if target_slug else {}),
834
+ },
835
+ )
836
+
837
+ def diff(self, base: str, head: str) -> Dict[str, Any]:
838
+ """Per-file diff (added/removed/modified/unchanged) between two version
839
+ ids, keyed on the stored per-file sha256."""
840
+ from urllib.parse import urlencode
841
+
842
+ qs = urlencode({"base": base, "head": head})
843
+ return self._get(f"/api/public/benchmark-containers/versions/diff?{qs}")
844
+
845
+ def install(self, slug_ref: str, *, tool: str = "uv", no_upgrade: bool = False) -> Dict[str, Any]:
846
+ """Install a version's built wheel by name (wheel-url-direct). Requires the
847
+ version to have been pushed with ``wheel=True``. ``tool`` is 'uv' or 'pip';
848
+ runs in the current Python environment (activate a venv first for uv)."""
849
+ if "@" in slug_ref:
850
+ slug, ref = slug_ref.rsplit("@", 1)
851
+ else:
852
+ slug, ref = slug_ref, "latest"
853
+ details = self.resolve_version(slug, ref)
854
+ version_id = details.get("versionId")
855
+ dl = self._get(f"/api/public/benchmark-containers/versions/{version_id}" "/download?artifact=wheel")
856
+ wheel_url = dl.get("downloadUrl")
857
+ if not wheel_url:
858
+ raise BenchmarkPushError(
859
+ f"{slug}@{ref} has no built wheel — push it with " "wheel=True (`gateway bench push --build-wheel`)"
860
+ )
861
+ # Download to a temp file with the canonical `.whl` basename (the object-key
862
+ # basename); a presigned URL basename carries a query string that pip/uv
863
+ # can't parse into a wheel name.
864
+ import tempfile
865
+ from urllib.parse import unquote, urlparse
866
+
867
+ wheel_name = os.path.basename(unquote(urlparse(wheel_url).path)) or "pkg.whl"
868
+ tmpdir = tempfile.mkdtemp(prefix="gateway-wheel-")
869
+ wheel_file = os.path.join(tmpdir, wheel_name)
870
+ try:
871
+ with (
872
+ urllib.request.urlopen(urllib.request.Request(wheel_url), timeout=300) as r,
873
+ open(wheel_file, "wb") as f,
874
+ ):
875
+ shutil.copyfileobj(r, f)
876
+ except urllib.error.HTTPError as e:
877
+ raise BenchmarkPushError(f"wheel download -> {e.code}") from None
878
+
879
+ if tool == "uv":
880
+ cmd = ["uv", "pip", "install"]
881
+ if not no_upgrade:
882
+ cmd.append("--reinstall")
883
+ else:
884
+ cmd = ["pip", "install"]
885
+ if not no_upgrade:
886
+ cmd.append("--upgrade")
887
+ cmd.append(wheel_file)
888
+ try:
889
+ subprocess.run(cmd, check=True)
890
+ except FileNotFoundError:
891
+ raise BenchmarkPushError(
892
+ f"'{cmd[0]}' is not installed or not on PATH — install it "
893
+ "(https://docs.astral.sh/uv/) or rerun with tool='pip' "
894
+ "(`gateway bench install --tool pip`)."
895
+ ) from None
896
+ except subprocess.CalledProcessError as e:
897
+ raise BenchmarkPushError(f"install failed (exit {e.returncode})") from None
898
+ finally:
899
+ shutil.rmtree(tmpdir, ignore_errors=True)
900
+ return {
901
+ "slug": slug,
902
+ "ref": ref,
903
+ "versionId": version_id,
904
+ "tool": tool,
905
+ "wheelFile": wheel_name,
906
+ }
907
+
908
+
909
+ def main(argv: Optional[List[str]] = None) -> int:
910
+ """`python -m gatewaysdk.benchmark_hub.client push ./env [--slug ...]`."""
911
+ import argparse
912
+
913
+ parser = argparse.ArgumentParser(prog="gateway bench")
914
+ sub = parser.add_subparsers(dest="cmd", required=True)
915
+ push_p = sub.add_parser("push", help="Push a benchmark container to Gateway")
916
+ push_p.add_argument("path")
917
+ push_p.add_argument("--host", default=None)
918
+ push_p.add_argument("--slug", default=None)
919
+ push_p.add_argument("--name", default=None)
920
+ push_p.add_argument("--description", default=None)
921
+ push_p.add_argument("--tag", action="append", dest="tags", default=None)
922
+ push_p.add_argument("--visibility", choices=["PRIVATE", "PUBLIC"], default=None)
923
+ push_p.add_argument("--auto-bump", action="store_true", dest="auto_bump")
924
+ push_p.add_argument("--rc", action="store_true")
925
+ push_p.add_argument("--post", action="store_true")
926
+ push_p.add_argument("--image", dest="image_ref", default=None)
927
+ push_p.add_argument("--build-wheel", action="store_true", dest="build_wheel")
928
+
929
+ pull_p = sub.add_parser("pull", help="Reconstruct a container version's source dir by name")
930
+ pull_p.add_argument("slug", help="slug or slug@latest|@semver|@hash")
931
+ pull_p.add_argument("--host", default=None)
932
+ pull_p.add_argument("--target", "-t", default=None)
933
+
934
+ fork_p = sub.add_parser("fork", help="Fork a container head into a new container")
935
+ fork_p.add_argument("slug", help="source container slug (fork the head)")
936
+ fork_p.add_argument("--host", default=None)
937
+ fork_p.add_argument("--into", dest="target_slug", default=None)
938
+
939
+ diff_p = sub.add_parser("diff", help="Per-file diff between two version ids")
940
+ diff_p.add_argument("base", help="base version id")
941
+ diff_p.add_argument("head", help="head version id")
942
+ diff_p.add_argument("--host", default=None)
943
+
944
+ install_p = sub.add_parser("install", help="pip-install a version's wheel by name")
945
+ install_p.add_argument("slug", help="slug or slug@latest|@semver|@hash")
946
+ install_p.add_argument("--host", default=None)
947
+ install_p.add_argument("--tool", choices=["uv", "pip"], default="uv")
948
+ install_p.add_argument("--no-upgrade", action="store_true", dest="no_upgrade")
949
+ args = parser.parse_args(argv)
950
+
951
+ if args.cmd == "push":
952
+ client = GatewayBenchmarkClient.from_env(host=args.host)
953
+ result = client.push(
954
+ args.path,
955
+ slug=args.slug,
956
+ name=args.name,
957
+ description=args.description,
958
+ tags=args.tags,
959
+ visibility=args.visibility,
960
+ auto_bump=args.auto_bump,
961
+ rc=args.rc,
962
+ post=args.post,
963
+ image_ref=args.image_ref,
964
+ wheel=args.build_wheel,
965
+ )
966
+ print(json.dumps(result, indent=2))
967
+ return 0
968
+ if args.cmd == "pull":
969
+ client = GatewayBenchmarkClient.from_env(host=args.host)
970
+ result = client.pull(args.slug, target=args.target)
971
+ print(json.dumps(result, indent=2))
972
+ return 0
973
+ if args.cmd == "fork":
974
+ client = GatewayBenchmarkClient.from_env(host=args.host)
975
+ result = client.fork(args.slug, target_slug=args.target_slug)
976
+ print(json.dumps(result, indent=2))
977
+ return 0
978
+ if args.cmd == "diff":
979
+ client = GatewayBenchmarkClient.from_env(host=args.host)
980
+ result = client.diff(args.base, args.head)
981
+ print(json.dumps(result, indent=2))
982
+ return 0
983
+ if args.cmd == "install":
984
+ client = GatewayBenchmarkClient.from_env(host=args.host)
985
+ result = client.install(args.slug, tool=args.tool, no_upgrade=args.no_upgrade)
986
+ print(json.dumps(result, indent=2))
987
+ return 0
988
+ return 1
989
+
990
+
991
+ if __name__ == "__main__": # pragma: no cover
992
+ raise SystemExit(main())