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,1628 @@
1
+ """The world's API: the vendor's HTTP surface, served from the world itself.
2
+
3
+ A provider's client speaks the vendor's REST paths, not tool calls. The world
4
+ answers those paths from its store, so the same ``connector.toml`` that
5
+ captured from the real service now describes how the world serves it —
6
+ reads, writes, and whatever logic sits between them:
7
+
8
+ [api]
9
+ envelope = "results" # rows land under this key with hits and cursor; "" = bare array
10
+ hits_key = "hits" # the envelope's count and next-page keys, named the vendor's way
11
+ cursor_key = "cursor"
12
+ page = "cursor" # none | offset | cursor: how list routes paginate
13
+ limit_param = "limit" # the query parameters the vendor's client sends for a page
14
+ offset_param = "offset"
15
+ cursor_param = "cursor"
16
+ page_size = 100
17
+ unauthorized = '{"detail": "Authentication credentials were not provided."}'
18
+ not_found = '{"detail": "Not found."}'
19
+ invalid = '{"detail": "Invalid request."}'
20
+ error_content_type = "application/problem+json" # what error bodies are labelled; the vendor's choice
21
+ unauthorized_status = 401 # some vendors answer 403 for a bad key; say what the vendor says
22
+ not_found_status = 404
23
+ invalid_status = 400
24
+ token_path = "/oauth/token" # oauth2 worlds mint bearer tokens here, the way the vendor does
25
+ handlers = "api/handlers.py" # the world's own logic (default when the file exists)
26
+ before = "gate" # optional: runs before every operation; returns None or an answer
27
+
28
+ [[operations]]
29
+ name = "get_records_by_email"
30
+ path = "/breach/data/emails/{email}"
31
+ entity = "spycloud_records"
32
+ [operations.api]
33
+ filter = { email = "attributes.email" } # path argument or query parameter -> row field (dotted),
34
+ # matched case-insensitively; an absent parameter does not filter
35
+
36
+ [[operations]]
37
+ name = "create_search"
38
+ method = "POST"
39
+ path = "/searches"
40
+ entity = "torchlight_searches"
41
+ [operations.api]
42
+ action = "create" # read | create | update | delete | handler
43
+ status = 201 # what the vendor answers with
44
+ single = true # the row itself, not a list envelope
45
+ body = { name = "name", description = "description" } # row field <- request body path
46
+ defaults = { status = "drafted" }
47
+ generate = { id = "uuid", createdAt = "now" } # uuid | int | now
48
+
49
+ [[operations]]
50
+ name = "execute_search"
51
+ method = "POST"
52
+ path = "/searches/{id}/execute"
53
+ entity = "torchlight_searches"
54
+ [operations.api]
55
+ action = "handler"
56
+ handler = "execute_search" # def execute_search(ctx, args, query, body) in api/handlers.py
57
+
58
+ An ``action = "handler"`` operation runs the world's own Python: ``ctx`` reads
59
+ and writes the store (``rows``, ``get``, ``insert``, ``update``, ``delete``),
60
+ tells the time (``now``, ``elapsed``, ``ladder`` for status machines that
61
+ advance on a clock), mints ids (``uuid``) and wraps answers (``envelope``,
62
+ ``not_found``). That is where computed results and workflow transitions live,
63
+ in the customer's world, never in this package.
64
+
65
+ ``serve`` runs the world's API locally with the standard library alone.
66
+ ``build_api_bundle`` writes the api-kind bundle the platform hosts (``gateway-api-world.toml``,
67
+ ``openapi.json``, ``seed/``, ``scenarios/``, ``server/``): the compiled world
68
+ and this package are copied into ``server/`` so the container needs nothing
69
+ installed, and the bundle root stays legacy-shaped because the api runner
70
+ refuses a schema bundle at its root. The runner's admin endpoints
71
+ (``/healthz``, ``/__world/seed``, ``/__world/reset``, ``/__world/state``) are
72
+ served beside the vendor routes.
73
+ """
74
+
75
+ from __future__ import annotations
76
+
77
+ import base64
78
+ import importlib.util
79
+ import json
80
+ import os
81
+ import re
82
+ import secrets
83
+ import shutil
84
+ import sys
85
+ import tempfile
86
+ import threading
87
+ import time
88
+ import tomllib
89
+ import urllib.parse
90
+ import uuid
91
+ from dataclasses import dataclass, field
92
+ from datetime import datetime, timezone
93
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
94
+ from pathlib import Path
95
+ from typing import Any, Callable
96
+
97
+ from . import snapshot as snapshots
98
+ from .compiler import check_bundle, compile_bundle
99
+ from .connector import DEFAULT_TOKEN_PATH, SPEC, Operation, Spec, load_spec
100
+ from .store import SQLiteStore
101
+ from .validation import ContractError, fail, read_json
102
+
103
+ ADMIN = {"seed": "/__world/seed", "reset": "/__world/reset", "state": "/__world/state", "health": "/healthz"}
104
+ PORT = 8080
105
+ FILTER_KEY = "api"
106
+ HANDLERS = "api/handlers.py"
107
+ SCENARIOS = "scenarios"
108
+ BEFORE = "__before__"
109
+ ACTIONS = ("read", "create", "update", "delete", "handler")
110
+ MATCHES = ("equals", "contains", "prefix")
111
+ PAGING = ("none", "offset", "cursor")
112
+ GENERATORS = ("uuid", "int", "now")
113
+ #: Set to an epoch to freeze the world's clock (tests, reproducible workflows).
114
+ CLOCK_ENV = "GATEWAY_WORLD_CLOCK"
115
+
116
+
117
+ # ---------------------------------------------------------------- settings
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class Paging:
122
+ style: str = "none"
123
+ limit_param: str = "limit"
124
+ offset_param: str = "offset"
125
+ cursor_param: str = "cursor"
126
+ page_size: int = 100
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class OperationSettings:
131
+ filter: dict[str, str] = field(default_factory=dict) # arg -> row field path
132
+ match: dict[str, str] = field(default_factory=dict) # arg -> equals | contains | prefix
133
+ action: str = "read"
134
+ status: int = 200
135
+ envelope: str | None = None # None inherits [api].envelope
136
+ single: bool = False
137
+ body: dict[str, str] = field(default_factory=dict) # row field <- body path; empty = the body as-is
138
+ defaults: dict[str, Any] = field(default_factory=dict)
139
+ generate: dict[str, str] = field(default_factory=dict)
140
+ handler: str | None = None
141
+ paging: str | None = None # None inherits [api].page
142
+ sample: dict[str, str] = field(default_factory=dict) # arg -> a value the route accepts, for conformance runs
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class UiSettings:
147
+ """``[ui]`` in connector.toml: a prebuilt static single-page app shipped
148
+ with the world and served by the World Host on the session's ui origin."""
149
+
150
+ #: Directory in the world holding ``index.html`` and its assets.
151
+ static: str
152
+ #: The page an agent lands on first.
153
+ entry: str = "/"
154
+ #: Same-origin prefix the SPA calls the api at; the host rewrites it.
155
+ api_path: str = "/__world-api"
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class ApiSettings:
160
+ envelope: str
161
+ unauthorized: Any
162
+ not_found: Any
163
+ filters: dict[str, dict[str, str]] # operation -> {arg: row field path}; kept for callers that read it
164
+ unauthorized_status: int = 401
165
+ not_found_status: int = 404
166
+ invalid: Any = None
167
+ invalid_status: int = 400
168
+ error_content_type: str = "application/json"
169
+ hits_key: str = "hits"
170
+ cursor_key: str = "cursor"
171
+ previous_key: str | None = None # emit the previous page's cursor under this key (DRF's "previous")
172
+ page_headers: dict[str, str] = field(default_factory=dict) # cursor | total | previous -> response header name
173
+ page_links: bool = False # next/previous as absolute URLs of the served host, the way DRF answers
174
+ paging: Paging = field(default_factory=Paging)
175
+ token_path: str | None = None
176
+ handlers: Path | None = None
177
+ before: str | None = None # a function in the handlers module run before every operation
178
+ operations: dict[str, OperationSettings] = field(default_factory=dict)
179
+ ui: UiSettings | None = None
180
+
181
+ def for_operation(self, name: str) -> OperationSettings:
182
+ return self.operations.get(name, OperationSettings())
183
+
184
+
185
+ def _json_body(table: dict, key: str, default: Any, location: str) -> Any:
186
+ text = table.get(key)
187
+ if text is None:
188
+ return default
189
+ if not isinstance(text, str):
190
+ fail("spec", f"{key} must be a JSON string", f"{location}/{key}")
191
+ try:
192
+ return json.loads(text)
193
+ except json.JSONDecodeError:
194
+ fail("spec", f"{key} is not valid JSON", f"{location}/{key}")
195
+
196
+
197
+ def _status(table: dict, key: str, default: int, location: str, *, low: int, high: int) -> int:
198
+ value = table.get(key, default)
199
+ if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
200
+ fail("spec", f"{key} must be an HTTP status between {low} and {high}", f"{location}/{key}")
201
+ return value
202
+
203
+
204
+ def _page_headers(api: dict) -> dict[str, str]:
205
+ """``page_headers = { cursor = "Cursor", total = "Total-Count" }``: vendors that page by
206
+ response header (Torchlight) get the same values there as in the envelope."""
207
+ mapping = api.get("page_headers", {})
208
+ if not isinstance(mapping, dict) or not all(
209
+ k in ("cursor", "total", "previous") and isinstance(v, str) and v.strip() for k, v in mapping.items()
210
+ ):
211
+ fail("spec", "api.page_headers maps cursor, total and previous to response header names", "/api/page_headers")
212
+ return dict(mapping)
213
+
214
+
215
+ def _flag(table: dict, key: str, location: str) -> bool:
216
+ value = table.get(key, False)
217
+ if not isinstance(value, bool):
218
+ fail("spec", f"{key} must be true or false", f"{location}/{key}")
219
+ return value
220
+
221
+
222
+ def _name(table: dict, key: str, default: str, location: str) -> str:
223
+ value = table.get(key, default)
224
+ if not isinstance(value, str) or not value.strip():
225
+ fail("spec", f"{key} must be a non-empty string", f"{location}/{key}")
226
+ return value
227
+
228
+
229
+ def _str_map(table: dict, key: str, location: str) -> dict[str, str]:
230
+ mapping = table.get(key, {})
231
+ if not isinstance(mapping, dict) or not all(
232
+ isinstance(k, str) and isinstance(v, str) and k for k, v in mapping.items()
233
+ ):
234
+ fail("spec", f"{key} maps names to strings", f"{location}/{key}")
235
+ return dict(mapping)
236
+
237
+
238
+ def _operation_settings(entry: dict, operation: Operation, location: str) -> OperationSettings:
239
+ table = entry.get(FILTER_KEY, {})
240
+ if not isinstance(table, dict):
241
+ fail("spec", "[operations.api] must be a table", location)
242
+ mapping = _str_map(table, "filter", location)
243
+ for arg in mapping:
244
+ if arg not in operation.placeholders and arg not in operation.params:
245
+ fail(
246
+ "spec",
247
+ f"api.filter names {arg}, which is neither a path argument nor a parameter",
248
+ f"{location}/filter",
249
+ )
250
+ match = _str_map(table, "match", location)
251
+ for arg, mode in match.items():
252
+ if arg not in mapping:
253
+ fail("spec", f"api.match names {arg}, which api.filter does not declare", f"{location}/match")
254
+ if mode not in MATCHES:
255
+ fail("spec", f"api.match.{arg} must be one of {', '.join(MATCHES)}", f"{location}/match")
256
+ action = _name(table, "action", "read", location)
257
+ if action not in ACTIONS:
258
+ fail("spec", f"api.action must be one of {', '.join(ACTIONS)}", f"{location}/action")
259
+ if action in ("create", "update", "delete") and not operation.entity:
260
+ fail("spec", f"api.action {action} needs an entity to write", f"{location}/action")
261
+ if action in ("update", "delete") and not mapping:
262
+ fail("spec", f"api.action {action} needs a filter to find the row", f"{location}/filter")
263
+ handler = table.get("handler")
264
+ if action == "handler":
265
+ if not isinstance(handler, str) or not re.match(r"^[a-z_][a-z0-9_]*$", handler):
266
+ fail("spec", "api.handler names a function in the world's handlers module", f"{location}/handler")
267
+ elif handler is not None:
268
+ fail("spec", 'api.handler belongs to action = "handler"', f"{location}/handler")
269
+ default_status = {"create": 201, "delete": 204}.get(action, 200)
270
+ status = _status(table, "status", default_status, location, low=200, high=299)
271
+ envelope = table.get("envelope")
272
+ if envelope is not None and not isinstance(envelope, str):
273
+ fail("spec", "api.envelope must be a string", f"{location}/envelope")
274
+ single = table.get("single", action in ("create", "update"))
275
+ if not isinstance(single, bool):
276
+ fail("spec", "api.single must be true or false", f"{location}/single")
277
+ body = _str_map(table, "body", location)
278
+ defaults = table.get("defaults", {})
279
+ if not isinstance(defaults, dict):
280
+ fail("spec", "api.defaults is a table of constant fields", f"{location}/defaults")
281
+ generate = _str_map(table, "generate", location)
282
+ for field_name, kind in generate.items():
283
+ if kind not in GENERATORS:
284
+ fail("spec", f"api.generate.{field_name} must be one of {', '.join(GENERATORS)}", f"{location}/generate")
285
+ if (body or defaults or generate) and action not in ("create", "update"):
286
+ fail("spec", "api.body, defaults and generate belong to create and update actions", location)
287
+ sample = table.get("sample", {})
288
+ if not isinstance(sample, dict) or not all(
289
+ isinstance(k, str) and isinstance(v, (str, int, float)) for k, v in sample.items()
290
+ ):
291
+ fail("spec", "api.sample maps path arguments or parameters to a value the route accepts", f"{location}/sample")
292
+ for arg in sample:
293
+ if arg not in operation.placeholders and arg not in operation.params:
294
+ fail(
295
+ "spec",
296
+ f"api.sample names {arg}, which is neither a path argument nor a parameter",
297
+ f"{location}/sample",
298
+ )
299
+ paging = table.get("page")
300
+ if paging is not None and paging not in PAGING:
301
+ fail("spec", f"api.page must be one of {', '.join(PAGING)}", f"{location}/page")
302
+ return OperationSettings(
303
+ filter=mapping,
304
+ match=match,
305
+ action=action,
306
+ status=status,
307
+ envelope=envelope,
308
+ single=single,
309
+ body=body,
310
+ defaults=dict(defaults),
311
+ generate=generate,
312
+ handler=handler if action == "handler" else None,
313
+ paging=paging,
314
+ sample={k: str(v) for k, v in sample.items()},
315
+ )
316
+
317
+
318
+ # Directory names every pusher (``bench push`` in Python and TypeScript, the
319
+ # web upload) drops from a bundle at any depth, as build artifacts. A built ui
320
+ # under one of them never reaches the hub, so the manifest refuses it here
321
+ # rather than the host refusing every open of the version.
322
+ ARCHIVE_DROPPED_DIRS = frozenset({"dist", "build", "outputs", "__pycache__"})
323
+
324
+
325
+ def _ui_settings(raw: dict, world: Path) -> UiSettings | None:
326
+ ui = raw.get("ui")
327
+ if ui is None:
328
+ return None
329
+ if not isinstance(ui, dict):
330
+ fail("spec", "[ui] must be a table", "/ui")
331
+ static = ui.get("static")
332
+ if not isinstance(static, str) or not static or static.startswith("/") or ".." in static.split("/"):
333
+ fail("spec", "ui.static is a directory inside the world holding the built ui", "/ui/static")
334
+ dropped = [part for part in static.split("/") if part in ARCHIVE_DROPPED_DIRS or part.startswith(".")]
335
+ if dropped:
336
+ fail(
337
+ "spec",
338
+ f"ui.static = {static!r} is under {dropped[0]!r}, which bench push drops from every bundle "
339
+ "as a build artifact; build the ui into a directory such as ui/static",
340
+ "/ui/static",
341
+ )
342
+ if not (world / static / "index.html").is_file():
343
+ fail("spec", f"ui.static = {static!r} has no index.html; build the ui first", "/ui/static")
344
+ entry = ui.get("entry", "/")
345
+ if not isinstance(entry, str) or not entry.startswith("/"):
346
+ fail("spec", "ui.entry is the path the agent lands on, starting with /", "/ui/entry")
347
+ api_path = ui.get("api_path", "/__world-api")
348
+ if not isinstance(api_path, str) or not api_path.startswith("/") or api_path == "/":
349
+ fail("spec", "ui.api_path is the same-origin prefix the ui calls the api at", "/ui/api_path")
350
+ return UiSettings(static=static.rstrip("/"), entry=entry, api_path=api_path.rstrip("/"))
351
+
352
+
353
+ def load_api_settings(world: Path, spec: Spec) -> ApiSettings:
354
+ raw = tomllib.loads((world / SPEC).read_text(encoding="utf-8"))
355
+ api = raw.get(FILTER_KEY, {})
356
+ if not isinstance(api, dict):
357
+ fail("spec", "[api] must be a table", "/api")
358
+ envelope = api.get("envelope", "")
359
+ if not isinstance(envelope, str):
360
+ fail("spec", "api.envelope must be a string", "/api/envelope")
361
+ style = _name(api, "page", "none", "/api")
362
+ if style not in PAGING:
363
+ fail("spec", f"api.page must be one of {', '.join(PAGING)}", "/api/page")
364
+ page_size = api.get("page_size", 100)
365
+ if isinstance(page_size, bool) or not isinstance(page_size, int) or page_size < 1:
366
+ fail("spec", "api.page_size must be a positive integer", "/api/page_size")
367
+ paging = Paging(
368
+ style=style,
369
+ limit_param=_name(api, "limit_param", "limit", "/api"),
370
+ offset_param=_name(api, "offset_param", "offset", "/api"),
371
+ cursor_param=_name(api, "cursor_param", "cursor", "/api"),
372
+ page_size=page_size,
373
+ )
374
+ token_path = api.get("token_path")
375
+ if token_path is not None and (not isinstance(token_path, str) or not token_path.startswith("/")):
376
+ fail("spec", "api.token_path is the path the world mints tokens on", "/api/token_path")
377
+ if token_path is not None and spec.scheme != "oauth2":
378
+ fail("spec", "api.token_path belongs to auth.scheme oauth2", "/api/token_path")
379
+ if spec.scheme == "oauth2" and token_path is None:
380
+ token_path = spec.token_url if (spec.token_url or "").startswith("/") else DEFAULT_TOKEN_PATH
381
+ handlers_name = api.get("handlers")
382
+ if handlers_name is not None and not isinstance(handlers_name, str):
383
+ fail("spec", "api.handlers is the path of the world's handlers module", "/api/handlers")
384
+ handlers = world / (handlers_name or HANDLERS)
385
+ if handlers_name is not None and not handlers.is_file():
386
+ fail("spec", f"api.handlers names {handlers_name}, which is not a file in the world", "/api/handlers")
387
+ operations: dict[str, OperationSettings] = {}
388
+ for index, entry in enumerate(raw.get("operations", [])):
389
+ location = f"/operations/{index}/api"
390
+ operation = spec.operations[entry["name"]]
391
+ operations[operation.name] = _operation_settings(entry, operation, location)
392
+ before = api.get("before")
393
+ if before is not None and (not isinstance(before, str) or not re.match(r"^[a-z_][a-z0-9_]*$", before)):
394
+ fail("spec", "api.before names a function in the world's handlers module", "/api/before")
395
+ needs_handlers = [name for name, op in operations.items() if op.handler] + (
396
+ [f"[api].before={before}"] if before else []
397
+ )
398
+ if needs_handlers and not handlers.is_file():
399
+ fail(
400
+ "spec",
401
+ f"Operations {', '.join(needs_handlers)} name handlers but the world has no {handlers.name}",
402
+ "/api/handlers",
403
+ )
404
+ return ApiSettings(
405
+ envelope=envelope,
406
+ unauthorized=_json_body(api, "unauthorized", {"error": "unauthorized"}, "/api"),
407
+ not_found=_json_body(api, "not_found", {"error": "not_found"}, "/api"),
408
+ filters={name: dict(op.filter) for name, op in operations.items()},
409
+ unauthorized_status=_status(api, "unauthorized_status", 401, "/api", low=400, high=599),
410
+ not_found_status=_status(api, "not_found_status", 404, "/api", low=400, high=599),
411
+ invalid=_json_body(api, "invalid", {"error": "invalid"}, "/api"),
412
+ invalid_status=_status(api, "invalid_status", 400, "/api", low=400, high=599),
413
+ error_content_type=_name(api, "error_content_type", "application/json", "/api"),
414
+ hits_key=_name(api, "hits_key", "hits", "/api"),
415
+ cursor_key=_name(api, "cursor_key", "cursor", "/api"),
416
+ previous_key=_name(api, "previous_key", "", "/api") if api.get("previous_key") is not None else None,
417
+ page_headers=_page_headers(api),
418
+ page_links=_flag(api, "page_links", "/api"),
419
+ paging=paging,
420
+ token_path=token_path,
421
+ handlers=handlers if handlers.is_file() else None,
422
+ before=before,
423
+ operations=operations,
424
+ ui=_ui_settings(raw, world),
425
+ )
426
+
427
+
428
+ # ---------------------------------------------------------------- state
429
+
430
+
431
+ class WorldState:
432
+ """The store behind the served API.
433
+
434
+ With a compiled snapshot in the tree (``db/state.sqlite`` whose sidecar names
435
+ this contract) a reset is a file copy and an open; without one the world's
436
+ initial batch is replayed, as it always was. ``snapshot`` is the sidecar of the
437
+ snapshot in use, or None on the legacy path."""
438
+
439
+ def __init__(self, world: Path):
440
+ self.world = world
441
+ self.contract = check_bundle(world)
442
+ self._initial: dict | None = None
443
+ self._snapshot_path = snapshots.default_snapshot(world)
444
+ # Only a snapshot that IS the tree's data through this contract (or the
445
+ # platform's pin) is opened; one left behind by an earlier compile while the
446
+ # journals grew is stale, and the journals are replayed instead.
447
+ self.snapshot: dict | None = snapshots.current_default(world, self.contract)
448
+ self._dir = tempfile.TemporaryDirectory(prefix="gateway-world-api-")
449
+ self._lock = threading.Lock()
450
+ self.store: SQLiteStore | None = None
451
+ self.reset()
452
+
453
+ @property
454
+ def digest(self) -> str | None:
455
+ """The batch digest of the snapshot the world is on; None on the legacy path."""
456
+ return self.snapshot["batchDigest"] if self.snapshot else None
457
+
458
+ @property
459
+ def initial(self) -> dict:
460
+ """The world's initial batch, read when the legacy path needs it: a snapshot
461
+ start never parses the JSON at all."""
462
+ if self._initial is None:
463
+ self._initial = read_json(self.world / "data/initial.json")
464
+ return self._initial
465
+
466
+ def reset(self, *, initial: bool = True) -> None:
467
+ """A fresh store: the snapshot copied (or the world's initial batch applied),
468
+ or empty when a legacy seed is about to supply the whole state (the runner's
469
+ base + overlay seed carries the initial rows itself; applying both would
470
+ collide on every key)."""
471
+ with self._lock:
472
+ self._open(self._snapshot_path if initial and self.snapshot else None, initial=initial)
473
+
474
+ def _open(self, snapshot: Path | None, *, initial: bool = True) -> None:
475
+ if self.store is not None:
476
+ self.store.close()
477
+ database = Path(self._dir.name) / f"{uuid.uuid4().hex}.db"
478
+ if snapshot is not None:
479
+ self.store = snapshots.open_copy(snapshot, self.contract, database)
480
+ return
481
+ self.store = SQLiteStore.create(database, self.contract)
482
+ if initial:
483
+ self.store.apply_batch(self.initial)
484
+
485
+ def seed(self, body: Any) -> dict:
486
+ """The runner's seed. Two bodies: the legacy whole state ``{entity: [rows]}``
487
+ (inserted on top of an empty world) and the overlay ``{"base": digest, "rows":
488
+ {entity: [rows]}, "mode": append|replace}`` (the snapshot reset, then the rows
489
+ applied on top). An overlay whose base is not the snapshot this world is on is
490
+ refused with ``base_mismatch`` naming the expected digest: a version mismatch
491
+ is an error, never a silent full reseed."""
492
+ if not isinstance(body, dict):
493
+ fail("seed", "Seed must be an object of entity -> rows", "/seed")
494
+ if isinstance(body.get("base"), str) and isinstance(body.get("rows"), dict):
495
+ return self._seed_overlay(body)
496
+ mutations = []
497
+ for entity, rows in body.items():
498
+ if entity not in self.contract.entities:
499
+ fail("seed", f"Unknown entity {entity}", f"/seed/{entity}")
500
+ if not isinstance(rows, list):
501
+ fail("seed", "Rows must be an array", f"/seed/{entity}")
502
+ for row in rows:
503
+ mutations.append(
504
+ {"op": "insert", "entity": entity, "value": row, "source": {"record": f"api-seed:{entity}"}}
505
+ )
506
+ self.reset(initial=False)
507
+ if not mutations:
508
+ return {"applied": 0}
509
+ with self._lock:
510
+ assert self.store is not None
511
+ return self.store.apply_batch(
512
+ {
513
+ "id": f"api-seed-{uuid.uuid4().hex}",
514
+ "target": self.contract.target,
515
+ "mapping": {"id": "api-seed", "version": "1"},
516
+ "coverage": "partial",
517
+ "mutations": mutations,
518
+ }
519
+ )
520
+
521
+ def _seed_overlay(self, body: dict) -> dict:
522
+ from .data import MODES
523
+
524
+ mode = body.get("mode", "append")
525
+ if mode not in MODES:
526
+ fail("seed", f"mode must be one of {', '.join(MODES)}", "/seed/mode")
527
+ if body["base"] != self.digest:
528
+ fail("base_mismatch", "The overlay's base is not the snapshot this world is on", expected=self.digest)
529
+ self.reset()
530
+ return self.seed_live(body["rows"], mode)
531
+
532
+ def seed_live(self, rows: dict, mode: str = "append") -> dict:
533
+ """Rows onto the world as it stands, no reset: ``append`` upserts by primary
534
+ key, ``replace`` swaps the named entities. The session's seed, so tool calls
535
+ made so far and the scenario's own rows stay."""
536
+ from .data import MODES, seed_batch
537
+
538
+ if mode not in MODES:
539
+ fail("seed", f"mode must be one of {', '.join(MODES)}", "/seed/mode")
540
+ with self._lock:
541
+ assert self.store is not None
542
+ batch = seed_batch(self.store, self.contract, rows, mode, batch_id=f"api-seed-{uuid.uuid4().hex}")
543
+ return self.store.apply_batch(batch)
544
+
545
+ def scenarios(self) -> dict[str, Path]:
546
+ """The world's own ``scenarios/<name>/scenario.toml`` dirs: a starting state each."""
547
+ return _scenario_dirs(self.world)
548
+
549
+ def apply_scenario(self, name: str) -> None:
550
+ """Start the world in one of its scenarios: the base seed with the scenario's
551
+ ``seed/*.json`` overlays replacing whole entities, the way the platform's runner
552
+ does. A compiled scenario snapshot is opened as it is; otherwise the overlay is
553
+ applied on a fresh default."""
554
+ scenarios = self.scenarios()
555
+ if name not in scenarios:
556
+ fail(
557
+ "scenario",
558
+ f"No scenario {name}; the world has {', '.join(scenarios) or 'none'}",
559
+ f"/{SCENARIOS}/{name}",
560
+ )
561
+ built = snapshots.scenario_snapshot(self.world, name)
562
+ if self.snapshot and snapshots.current(
563
+ built,
564
+ snapshots.contract_hash(self.contract),
565
+ snapshots.scenario_batch_digest(scenarios[name], snapshots.expected_default_digest(self.world)),
566
+ ):
567
+ with self._lock:
568
+ self._open(built)
569
+ return
570
+ overlay = snapshots.scenario_overlay(scenarios[name])
571
+ if self.snapshot:
572
+ self.seed({"base": self.digest, "rows": overlay, "mode": "replace"})
573
+ return
574
+ base: dict[str, list] = {}
575
+ for mutation in self.initial.get("mutations", []):
576
+ if mutation.get("op") == "insert":
577
+ base.setdefault(mutation["entity"], []).append(mutation["value"])
578
+ base.update(overlay)
579
+ self.seed(base)
580
+
581
+ def rows(self, entity: str) -> list[dict]:
582
+ with self._lock:
583
+ assert self.store is not None
584
+ return list(self.store.list(entity))
585
+
586
+ def get(self, entity: str, key: dict) -> dict | None:
587
+ with self._lock:
588
+ assert self.store is not None
589
+ return self.store.get(entity, key)
590
+
591
+ def insert(self, entity: str, value: dict) -> dict:
592
+ """Persist a row the API created; the contract validates it like any ingest."""
593
+ with self._lock:
594
+ assert self.store is not None
595
+ return self.store.insert(entity, value, source={"record": "api.create"})
596
+
597
+ def update(self, entity: str, key: dict, patch: dict) -> dict:
598
+ with self._lock:
599
+ assert self.store is not None
600
+ return self.store.update(entity, key, patch, source={"record": "api.update"})
601
+
602
+ def delete(self, entity: str, key: dict) -> None:
603
+ with self._lock:
604
+ assert self.store is not None
605
+ self.store.delete(entity, key, source={"record": "api.delete"})
606
+
607
+ def key_of(self, entity: str, row: dict) -> dict:
608
+ return {name: row.get(name) for name in self.contract.entities[entity]["primary_key"]}
609
+
610
+ def dump(self) -> dict:
611
+ out: dict[str, dict[str, dict]] = {}
612
+ for entity, spec in self.contract.entities.items():
613
+ keyed = {}
614
+ for row in self.rows(entity):
615
+ key = ":".join(str(row.get(field)) for field in spec["primary_key"])
616
+ keyed[key] = row
617
+ out[entity] = keyed
618
+ return out
619
+
620
+ def describe_snapshot(self) -> dict | None:
621
+ """What ``/__world/state`` reports beside the rows: the snapshot in use, or null."""
622
+ if not self.snapshot:
623
+ return None
624
+ return {"batchDigest": self.snapshot["batchDigest"], "contractHash": self.snapshot["contractHash"]}
625
+
626
+ def close(self) -> None:
627
+ with self._lock:
628
+ if self.store is not None:
629
+ self.store.close()
630
+ self.store = None
631
+ self._dir.cleanup()
632
+
633
+
634
+ # ---------------------------------------------------------------- routing
635
+
636
+
637
+ def _dig(row: Any, path: str) -> Any:
638
+ for part in path.split("."):
639
+ if not isinstance(row, dict) or part not in row:
640
+ return None
641
+ row = row[part]
642
+ return row
643
+
644
+
645
+ def _matches(row: dict, filters: dict[str, str], values: dict[str, str], modes: dict[str, str] | None = None) -> bool:
646
+ """Every filter whose argument was supplied must match; a parameter left out does not narrow.
647
+
648
+ ``modes`` picks how: ``equals`` (default), ``contains`` (case-insensitive substring,
649
+ the vendor's ``icontains``) or ``prefix``. A list field matches when any element does."""
650
+ for arg, path in filters.items():
651
+ if arg not in values:
652
+ continue
653
+ value = _dig(row, path)
654
+ if value is None:
655
+ return False
656
+ mode = (modes or {}).get(arg, "equals")
657
+ wanted = values[arg].lower()
658
+ candidates = value if isinstance(value, list) else [value]
659
+ if not any(_compare(str(candidate).lower(), wanted, mode) for candidate in candidates):
660
+ return False
661
+ return True
662
+
663
+
664
+ def _compare(have: str, wanted: str, mode: str) -> bool:
665
+ if mode == "contains":
666
+ return wanted in have
667
+ if mode == "prefix":
668
+ return have.startswith(wanted)
669
+ return have == wanted
670
+
671
+
672
+ class Router:
673
+ def __init__(self, spec: Spec, settings: ApiSettings):
674
+ self.spec = spec
675
+ self.settings = settings
676
+ self.routes = [(operation, self._pattern(operation)) for operation in spec.operations.values()]
677
+ self.tokens: set[str] = set()
678
+ #: refresh token -> the subject it was minted for (password / refresh grants)
679
+ self._refresh_tokens: dict[str, str] = {}
680
+ self._jwt_secret = secrets.token_bytes(32)
681
+ self._lock = threading.Lock()
682
+
683
+ @staticmethod
684
+ def _pattern(operation: Operation) -> re.Pattern:
685
+ escaped = re.escape(operation.path)
686
+ pattern = re.sub(r"\\\{([A-Za-z_][A-Za-z0-9_]*)\\\}", r"(?P<\1>[^/]+)", escaped)
687
+ return re.compile(f"^{pattern}$")
688
+
689
+ def match(self, method: str, path: str, query: dict[str, str]) -> tuple[Operation, dict[str, str]] | None:
690
+ """The operation for a request. Vendors reuse one path for several lookups that
691
+ differ only by query parameter, so among path matches the one whose declared
692
+ parameters the caller actually sent wins; ties keep declaration order."""
693
+ best: tuple[int, int, Operation, dict[str, str]] | None = None
694
+ for index, (operation, pattern) in enumerate(self.routes):
695
+ found = pattern.match(path)
696
+ if not found or operation.method != method:
697
+ continue
698
+ present = sum(1 for name in operation.params if name in query)
699
+ candidate = (present, -index, operation, {k: urllib.parse.unquote(v) for k, v in found.groupdict().items()})
700
+ if best is None or candidate[:2] > best[:2]:
701
+ best = candidate
702
+ return None if best is None else (best[2], best[3])
703
+
704
+ def issue_token(self, form: dict[str, str], key: str | None) -> dict | None:
705
+ """The vendor's token endpoint. Three grants, the way real vendors mint:
706
+
707
+ * ``client_credentials`` (the default): a bearer ``access_token``.
708
+ * ``password``: ``email`` + ``password`` in, an ``id_token`` (an HS256 JWT
709
+ carrying ``sub``/``iat``/``exp``, so a client that reads expiry from the
710
+ token itself sees a real one) plus a ``refresh_token``. This is how
711
+ Torchlight's clients authenticate.
712
+ * ``refresh_token``: a refresh token this world minted in, a fresh
713
+ ``id_token`` + ``refresh_token`` out; an unknown refresh token is refused.
714
+
715
+ With ``WORLD_API_KEY`` pinned the client secret (or the password) must be that
716
+ key, the way a real vendor knows its clients; unpinned, any grant is served.
717
+ Every bearer this world minted — access or id token — authorizes calls."""
718
+ grant = form.get("grant_type", "client_credentials")
719
+ if grant == "client_credentials":
720
+ if key is not None and form.get("client_secret") != key:
721
+ return None
722
+ token = secrets.token_urlsafe(32)
723
+ with self._lock:
724
+ self.tokens.add(token)
725
+ answer = {"access_token": token, "token_type": "Bearer", "expires_in": 3600}
726
+ if form.get("scope"):
727
+ answer["scope"] = form["scope"]
728
+ elif self.spec.scope:
729
+ answer["scope"] = self.spec.scope
730
+ return answer
731
+ if grant == "password":
732
+ subject = form.get("email") or form.get("username") or ""
733
+ if not subject or not form.get("password"):
734
+ return None
735
+ if key is not None and form.get("password") != key:
736
+ return None
737
+ return self._issue_identity(subject)
738
+ if grant == "refresh_token":
739
+ presented = form.get("refresh_token") or ""
740
+ with self._lock:
741
+ subject = self._refresh_tokens.pop(presented, None)
742
+ if subject is None:
743
+ return None
744
+ return self._issue_identity(subject)
745
+ return None
746
+
747
+ def _issue_identity(self, subject: str) -> dict:
748
+ """An ``id_token`` (HS256 JWT, one hour) and a ``refresh_token``, both
749
+ remembered so later calls and refreshes are honoured."""
750
+ import hmac
751
+ import hashlib
752
+
753
+ now = int(time.time())
754
+ header = base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode()
755
+ payload = base64.urlsafe_b64encode(
756
+ json.dumps(
757
+ {"sub": subject, "iat": now, "exp": now + 3600, "iss": self.spec.slug, "jti": secrets.token_urlsafe(9)},
758
+ separators=(",", ":"),
759
+ ).encode()
760
+ ).rstrip(b"=").decode()
761
+ signature = base64.urlsafe_b64encode(
762
+ hmac.new(self._jwt_secret, f"{header}.{payload}".encode(), hashlib.sha256).digest()
763
+ ).rstrip(b"=").decode()
764
+ id_token = f"{header}.{payload}.{signature}"
765
+ refresh = secrets.token_urlsafe(32)
766
+ with self._lock:
767
+ self.tokens.add(id_token)
768
+ self._refresh_tokens[refresh] = subject
769
+ return {
770
+ "id_token": id_token,
771
+ "access_token": id_token,
772
+ "refresh_token": refresh,
773
+ "token_type": "Bearer",
774
+ "expires_in": 3600,
775
+ }
776
+
777
+ def authorized(self, headers: dict[str, str], query: dict[str, str], key: str | None) -> bool:
778
+ spec = self.spec
779
+ if spec.scheme == "none":
780
+ return True
781
+ presented: str | None = None
782
+ if spec.scheme == "header":
783
+ presented = headers.get((spec.auth_name or "").lower())
784
+ elif spec.scheme in ("bearer", "oauth2"):
785
+ value = headers.get("authorization", "")
786
+ presented = value[7:] if value.lower().startswith("bearer ") else None
787
+ elif spec.scheme == "basic":
788
+ value = headers.get("authorization", "")
789
+ try:
790
+ presented = base64.b64decode(value[6:]).decode() if value.lower().startswith("basic ") else None
791
+ except (ValueError, UnicodeDecodeError):
792
+ presented = None
793
+ elif spec.scheme == "query":
794
+ presented = query.get(spec.auth_name or "")
795
+ if not presented:
796
+ return False
797
+ if spec.scheme == "oauth2":
798
+ # A bearer is good only if this world minted it: the client did the grant first.
799
+ with self._lock:
800
+ return presented in self.tokens
801
+ return key is None or presented == key
802
+
803
+
804
+ # ---------------------------------------------------------------- handlers
805
+
806
+
807
+ class ApiContext:
808
+ """What a world's handler gets: the store, the clock, and the vendor's shapes."""
809
+
810
+ def __init__(
811
+ self, state: WorldState, router: Router, operation: Operation, link: tuple[str, dict[str, str]] | None = None
812
+ ):
813
+ self.state = state
814
+ self.router = router
815
+ self.settings = router.settings
816
+ self.operation = operation
817
+ self.spec = router.spec
818
+ #: (absolute URL without query, query) of the request being answered, for page links.
819
+ self.link = link
820
+ #: The request's headers, lower-cased — a gate reads the credential from here.
821
+ self.headers: dict[str, str] = {}
822
+
823
+ # -- the store
824
+ def rows(self, entity: str) -> list[dict]:
825
+ return self.state.rows(entity)
826
+
827
+ def get(self, entity: str, key: dict) -> dict | None:
828
+ return self.state.get(entity, key)
829
+
830
+ def find(self, entity: str, **fields: Any) -> list[dict]:
831
+ wanted = {k: str(v) for k, v in fields.items()}
832
+ return [row for row in self.state.rows(entity) if _matches(row, {k: k for k in wanted}, wanted)]
833
+
834
+ def insert(self, entity: str, value: dict) -> dict:
835
+ return self.state.insert(entity, value)
836
+
837
+ def update(self, entity: str, key: dict, patch: dict) -> dict:
838
+ return self.state.update(entity, key, patch)
839
+
840
+ def delete(self, entity: str, key: dict) -> None:
841
+ self.state.delete(entity, key)
842
+
843
+ def key_of(self, entity: str, row: dict) -> dict:
844
+ return self.state.key_of(entity, row)
845
+
846
+ # -- time and identity
847
+ @staticmethod
848
+ def clock() -> float:
849
+ frozen = os.environ.get(CLOCK_ENV)
850
+ return float(frozen) if frozen else time.time()
851
+
852
+ def now(self) -> str:
853
+ return datetime.fromtimestamp(self.clock(), timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
854
+
855
+ def elapsed(self, since: str | None) -> float:
856
+ """Seconds since an ISO timestamp the world recorded; infinite when it never happened."""
857
+ if not since:
858
+ return float("inf")
859
+ started = datetime.fromisoformat(since.replace("Z", "+00:00"))
860
+ return self.clock() - started.timestamp()
861
+
862
+ def ladder(self, since: str | None, steps: list[tuple[float, Any]], before: Any = None) -> Any:
863
+ """A status machine on a clock: the last step whose delay has passed.
864
+
865
+ ``ladder(executed_at, [(0, "queued"), (5, "started"), (20, "completed")])``
866
+ reads ``queued`` right after execute, ``started`` five seconds later, and
867
+ ``completed`` from twenty seconds on. ``before`` is the answer when nothing
868
+ has started yet."""
869
+ if not since:
870
+ return before
871
+ passed = self.elapsed(since)
872
+ current = before
873
+ for delay, value in steps:
874
+ if passed >= delay:
875
+ current = value
876
+ return current
877
+
878
+ @staticmethod
879
+ def uuid() -> str:
880
+ return str(uuid.uuid4())
881
+
882
+ # -- the vendor's shapes
883
+ def envelope(self, rows: list[dict], query: dict[str, str] | None = None, *, envelope: str | None = None) -> Any:
884
+ page = paginate(rows, query or {}, self.settings.paging)
885
+ return wrap(page, self.settings, envelope, link=self.link)
886
+
887
+ def paged(
888
+ self, rows: list[dict], query: dict[str, str] | None = None, *, envelope: str | None = None, status: int = 200
889
+ ) -> Any:
890
+ """``envelope`` plus the vendor's paging headers, as a handler's full answer."""
891
+ page = paginate(rows, query or {}, self.settings.paging)
892
+ return status, wrap(page, self.settings, envelope, link=self.link), page_headers(page, self.settings)
893
+
894
+ def not_found(self) -> tuple[int, Any]:
895
+ return self.settings.not_found_status, self.settings.not_found
896
+
897
+ def invalid(self, detail: str | None = None) -> tuple[int, Any]:
898
+ body = self.settings.invalid
899
+ if detail and isinstance(body, dict):
900
+ body = {**body, "detail": detail}
901
+ return self.settings.invalid_status, body
902
+
903
+
904
+ Handler = Callable[[ApiContext, dict[str, str], dict[str, str], Any], Any]
905
+
906
+
907
+ def load_handlers(settings: ApiSettings) -> dict[str, Handler]:
908
+ """Import the world's handlers module once; every ``action = "handler"`` name must exist."""
909
+ if settings.handlers is None:
910
+ return {}
911
+ module_name = f"gateway_world_api_handlers_{uuid.uuid4().hex}"
912
+ spec = importlib.util.spec_from_file_location(module_name, settings.handlers)
913
+ if spec is None or spec.loader is None:
914
+ fail("handlers", f"Could not load {settings.handlers}", "/api/handlers")
915
+ module = importlib.util.module_from_spec(spec)
916
+ sys.modules[module_name] = module
917
+ try:
918
+ spec.loader.exec_module(module)
919
+ finally:
920
+ sys.modules.pop(module_name, None)
921
+ handlers: dict[str, Handler] = {}
922
+ if settings.before:
923
+ gate = getattr(module, settings.before, None)
924
+ if not callable(gate):
925
+ fail(
926
+ "handlers",
927
+ f"{settings.handlers.name} has no function {settings.before} for [api].before",
928
+ "/api/before",
929
+ )
930
+ handlers[BEFORE] = gate
931
+ for name, op in settings.operations.items():
932
+ if not op.handler:
933
+ continue
934
+ function = getattr(module, op.handler, None)
935
+ if not callable(function):
936
+ fail(
937
+ "handlers",
938
+ f"{settings.handlers.name} has no function {op.handler} for operation {name}",
939
+ "/api/handlers",
940
+ )
941
+ handlers[name] = function
942
+ return handlers
943
+
944
+
945
+ # ---------------------------------------------------------------- pages and envelopes
946
+
947
+
948
+ @dataclass(frozen=True)
949
+ class Page:
950
+ rows: list[dict]
951
+ total: int
952
+ offset: int
953
+ limit: int
954
+ style: str
955
+
956
+ @property
957
+ def next_offset(self) -> int | None:
958
+ following = self.offset + self.limit
959
+ return following if self.style != "none" and following < self.total else None
960
+
961
+ @property
962
+ def previous_offset(self) -> int | None:
963
+ return max(self.offset - self.limit, 0) if self.style != "none" and self.offset > 0 else None
964
+
965
+
966
+ def _encode_cursor(offset: int) -> str:
967
+ return base64.urlsafe_b64encode(str(offset).encode()).decode().rstrip("=")
968
+
969
+
970
+ def paginate(rows: list[dict], query: dict[str, str], paging: Paging) -> Page:
971
+ """One page of rows with the offsets the next and previous pages start at."""
972
+ total = len(rows)
973
+ if paging.style == "none":
974
+ return Page(rows, total, 0, total, "none")
975
+ limit = _int_param(query.get(paging.limit_param), paging.page_size)
976
+ offset = 0
977
+ if paging.style == "offset":
978
+ offset = _int_param(query.get(paging.offset_param), 0)
979
+ else:
980
+ raw = query.get(paging.cursor_param)
981
+ if raw:
982
+ try:
983
+ offset = int(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)).decode())
984
+ except (ValueError, UnicodeDecodeError):
985
+ offset = 0
986
+ return Page(rows[offset : offset + limit], total, offset, limit, paging.style)
987
+
988
+
989
+ def _int_param(value: str | None, default: int) -> int:
990
+ try:
991
+ parsed = int(value) if value is not None else default
992
+ except ValueError:
993
+ return default
994
+ return parsed if parsed >= 0 else default
995
+
996
+
997
+ def _page_ref(page: Page, offset: int | None, paging: Paging, link: tuple[str, dict[str, str]] | None) -> Any:
998
+ """What the vendor's client is handed for a neighbouring page: '' or None when there is
999
+ none, a cursor token, or — with ``page_links`` — the absolute URL DRF-style vendors answer."""
1000
+ if offset is None:
1001
+ return None if link is not None else ""
1002
+ if link is None:
1003
+ return _encode_cursor(offset)
1004
+ base, query = link
1005
+ following = dict(query)
1006
+ if page.style == "offset":
1007
+ following[paging.offset_param] = str(offset)
1008
+ else:
1009
+ following[paging.cursor_param] = _encode_cursor(offset)
1010
+ following[paging.limit_param] = str(page.limit)
1011
+ return f"{base}?{urllib.parse.urlencode(following)}"
1012
+
1013
+
1014
+ def page_headers(page: Page, settings: ApiSettings, paging: Paging | None = None) -> dict[str, str] | None:
1015
+ """The page's cursor/total/previous as response headers, when the vendor sends them that way."""
1016
+ if not settings.page_headers:
1017
+ return None
1018
+ paging = paging or settings.paging
1019
+ values = {
1020
+ "cursor": _page_ref(page, page.next_offset, paging, None),
1021
+ "total": str(page.total),
1022
+ "previous": _page_ref(page, page.previous_offset, paging, None),
1023
+ }
1024
+ return {header: str(values[key]) for key, header in settings.page_headers.items() if values[key] not in ("", None)}
1025
+
1026
+
1027
+ def wrap(
1028
+ page: Page | list[dict],
1029
+ settings: ApiSettings,
1030
+ envelope: str | None = None,
1031
+ *,
1032
+ paging: Paging | None = None,
1033
+ link: tuple[str, dict[str, str]] | None = None,
1034
+ ) -> Any:
1035
+ """Rows in the vendor's envelope: ``{<envelope>: rows, <hits_key>: total, <cursor_key>: next
1036
+ [, <previous_key>: previous]}``; an empty envelope key means the bare array."""
1037
+ if not isinstance(page, Page):
1038
+ page = Page(page, len(page), 0, len(page), "none")
1039
+ key = settings.envelope if envelope is None else envelope
1040
+ if not key:
1041
+ return page.rows
1042
+ paging = paging or settings.paging
1043
+ link = link if settings.page_links else None
1044
+ out = {
1045
+ key: page.rows,
1046
+ settings.hits_key: page.total,
1047
+ settings.cursor_key: _page_ref(page, page.next_offset, paging, link),
1048
+ }
1049
+ if settings.previous_key:
1050
+ out[settings.previous_key] = _page_ref(page, page.previous_offset, paging, link)
1051
+ return out
1052
+
1053
+
1054
+ def _normalize(result: Any, default_status: int | None) -> Any:
1055
+ """What a handler (or the gate) hands back, as ``(status, body, headers)``.
1056
+
1057
+ A handler returns a body, ``(status, body)`` or ``(status, body, headers)``;
1058
+ the gate returns ``None`` to let the request through or one of those to stop it."""
1059
+ if result is None and default_status is None:
1060
+ return None
1061
+ if (
1062
+ isinstance(result, tuple)
1063
+ and len(result) in (2, 3)
1064
+ and isinstance(result[0], int)
1065
+ and not isinstance(result[0], bool)
1066
+ ):
1067
+ status, body = result[0], result[1]
1068
+ extra = result[2] if len(result) == 3 else None
1069
+ if extra is not None and not (
1070
+ isinstance(extra, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in extra.items())
1071
+ ):
1072
+ fail("handlers", "A handler's headers are a mapping of header names to strings", "/api/handlers")
1073
+ return status, body, extra
1074
+ return default_status, result, None
1075
+
1076
+
1077
+ # ---------------------------------------------------------------- writes
1078
+
1079
+
1080
+ def _set(row: dict, target: str, value: Any, current: dict | None) -> None:
1081
+ """Assign a dotted target (``attributes.note``), keeping the rest of a nested
1082
+ object an update leaves alone."""
1083
+ parts = target.split(".")
1084
+ if len(parts) == 1:
1085
+ row[target] = value
1086
+ return
1087
+ head = parts[0]
1088
+ if head not in row:
1089
+ base = current.get(head) if current else None
1090
+ row[head] = json.loads(json.dumps(base)) if isinstance(base, dict) else {}
1091
+ node = row[head]
1092
+ for part in parts[1:-1]:
1093
+ if not isinstance(node.get(part), dict):
1094
+ node[part] = {}
1095
+ node = node[part]
1096
+ node[parts[-1]] = value
1097
+
1098
+
1099
+ def _bind(op: OperationSettings, ctx: ApiContext, body: Any, current: dict | None = None) -> dict:
1100
+ """The row a create or update writes: defaults, then the body (mapped or as-is), then generated fields."""
1101
+ if body is None or not isinstance(body, dict):
1102
+ body = {}
1103
+ row: dict = json.loads(json.dumps(op.defaults)) if current is None else {}
1104
+ if op.body:
1105
+ for field_name, path in op.body.items():
1106
+ value = _dig(body, path)
1107
+ if value is not None:
1108
+ _set(row, field_name, value, current)
1109
+ else:
1110
+ row.update(body)
1111
+ if current is None:
1112
+ for field_name, kind in op.generate.items():
1113
+ if row.get(field_name) is not None:
1114
+ continue
1115
+ if kind == "uuid":
1116
+ row[field_name] = ctx.uuid()
1117
+ elif kind == "now":
1118
+ row[field_name] = ctx.now()
1119
+ else:
1120
+ existing = [r.get(field_name) for r in ctx.rows(ctx.operation.entity or "")]
1121
+ row[field_name] = max([v for v in existing if isinstance(v, int)] + [0]) + 1
1122
+ return row
1123
+
1124
+
1125
+ # ---------------------------------------------------------------- the server
1126
+
1127
+
1128
+ Response = tuple[int, Any, dict[str, str] | None]
1129
+
1130
+
1131
+ class WorldApp:
1132
+ """One world's HTTP behaviour as a function of the request.
1133
+
1134
+ ``handle`` takes what a server read off the wire (method, request target,
1135
+ headers, raw body) and answers ``(status, body, extra headers)``; nothing in
1136
+ it touches a socket. ``make_handler`` wraps it for ``serve()``, and the
1137
+ World Host calls it for whichever session a request names, so one process
1138
+ can answer for many worlds without the handler knowing.
1139
+ """
1140
+
1141
+ def __init__(
1142
+ self,
1143
+ state: WorldState,
1144
+ router: Router,
1145
+ key: str | None,
1146
+ handlers: dict[str, Handler] | None = None,
1147
+ allowed: frozenset[str] | None = None,
1148
+ ):
1149
+ self.state = state
1150
+ self.router = router
1151
+ self.key = key
1152
+ self.settings = router.settings
1153
+ self.prefix = urllib.parse.urlsplit(router.spec.base_url).path.rstrip("/")
1154
+ self.handlers = load_handlers(self.settings) if handlers is None else handlers
1155
+ #: The operations this session's task allows, or None for all of them.
1156
+ #: A route outside the list answers 403 before any handler runs, so the
1157
+ #: list holds against a client that talks to the vendor API directly.
1158
+ self.allowed = allowed
1159
+
1160
+ # ------------------------------------------------------------ helpers
1161
+
1162
+ @staticmethod
1163
+ def _json(raw: bytes) -> Any:
1164
+ if not raw:
1165
+ return {}
1166
+ try:
1167
+ return json.loads(raw.decode("utf-8"))
1168
+ except (UnicodeDecodeError, json.JSONDecodeError):
1169
+ return None
1170
+
1171
+ @staticmethod
1172
+ def _form(raw: bytes, headers: dict[str, str]) -> dict[str, str]:
1173
+ content_type = (headers.get("content-type") or "").split(";")[0].strip().lower()
1174
+ if content_type == "application/json":
1175
+ try:
1176
+ parsed = json.loads(raw.decode("utf-8"))
1177
+ except (UnicodeDecodeError, json.JSONDecodeError):
1178
+ return {}
1179
+ return {k: str(v) for k, v in parsed.items()} if isinstance(parsed, dict) else {}
1180
+ form = {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode("utf-8", "replace")).items()}
1181
+ basic = headers.get("authorization", "")
1182
+ if basic.lower().startswith("basic "):
1183
+ try:
1184
+ client_id, _, client_secret = base64.b64decode(basic[6:]).decode().partition(":")
1185
+ form.setdefault("client_id", client_id)
1186
+ form.setdefault("client_secret", client_secret)
1187
+ except (ValueError, UnicodeDecodeError):
1188
+ pass
1189
+ return form
1190
+
1191
+ def _strip(self, path: str) -> str:
1192
+ if self.prefix and path.startswith(self.prefix):
1193
+ return path[len(self.prefix) :] or "/"
1194
+ return path
1195
+
1196
+ # ------------------------------------------------------------ the request
1197
+
1198
+ def handle(self, method: str, target: str, headers: dict[str, str], raw: bytes, default_host: str) -> Response:
1199
+ """Answer one request. ``headers`` are lower-cased; ``default_host`` is what
1200
+ a link uses when the request carries no Host header."""
1201
+ url = urllib.parse.urlsplit(target)
1202
+ settings, router, state = self.settings, self.router, self.state
1203
+ if method == "GET":
1204
+ if url.path == ADMIN["health"]:
1205
+ return 200, {"ok": True, "world": router.spec.slug}, None
1206
+ if url.path == ADMIN["state"]:
1207
+ return 200, {**state.dump(), "snapshot": state.describe_snapshot()}, None
1208
+ if method == "POST":
1209
+ if url.path == ADMIN["reset"]:
1210
+ state.reset()
1211
+ return 200, {"ok": True}, None
1212
+ if url.path == ADMIN["seed"]:
1213
+ body = self._json(raw)
1214
+ if body is None:
1215
+ return 400, {"error": "seed must be JSON"}, None
1216
+ try:
1217
+ state.seed(body)
1218
+ except ContractError as error:
1219
+ if error.detail.get("code") == "base_mismatch":
1220
+ return 409, {"error": "base_mismatch", "expected": error.detail.get("expected")}, None
1221
+ return 400, {"error": "seed_rejected", "detail": str(error)}, None
1222
+ except Exception as error: # a rejected seed is the caller's problem, reported plainly
1223
+ return 400, {"error": "seed_rejected", "detail": str(error)}, None
1224
+ return 200, {"ok": True}, None
1225
+ if settings.token_path and self._strip(url.path) == settings.token_path:
1226
+ issued = router.issue_token(self._form(raw, headers), self.key)
1227
+ if issued is None:
1228
+ return 400, {"error": "invalid_client"}, None
1229
+ return 200, issued, None
1230
+ if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
1231
+ return 405, {"error": "method_not_allowed"}, None
1232
+ return self._route(method, url, headers, raw, default_host)
1233
+
1234
+ def _route(self, method: str, url, headers: dict[str, str], raw: bytes, default_host: str) -> Response:
1235
+ settings, router, state, handlers = self.settings, self.router, self.state, self.handlers
1236
+ path = self._strip(url.path)
1237
+ query = {k: v[0] for k, v in urllib.parse.parse_qs(url.query).items()}
1238
+ if not router.authorized(headers, query, self.key):
1239
+ return settings.unauthorized_status, settings.unauthorized, None
1240
+ matched = router.match(method, path, query)
1241
+ if matched is None:
1242
+ return settings.not_found_status, settings.not_found, None
1243
+ operation, args = matched
1244
+ if self.allowed is not None and operation.name not in self.allowed:
1245
+ return 403, {"error": "tool_not_allowed", "tool": operation.name}, None
1246
+ op = settings.for_operation(operation.name)
1247
+ body = self._json(raw) if method in ("POST", "PUT", "PATCH") else {}
1248
+ if body is None:
1249
+ return settings.invalid_status, settings.invalid, None
1250
+ host = headers.get("host") or default_host
1251
+ forwarded = (headers.get("x-forwarded-proto") or "http").split(",")[0].strip()
1252
+ ctx = ApiContext(state, router, operation, link=(f"{forwarded}://{host}{url.path}", query))
1253
+ ctx.headers = headers
1254
+ try:
1255
+ if BEFORE in handlers:
1256
+ # The world's gate: quotas, allow-lists, key states — whatever the vendor
1257
+ # checks before any route, declarative ones included.
1258
+ verdict = _normalize(handlers[BEFORE](ctx, args, query, body), None)
1259
+ if verdict is not None:
1260
+ return verdict
1261
+ status, answer, extra = _normalize(self._answer(op, operation, ctx, args, query, body, handlers), op.status)
1262
+ except ContractError as error:
1263
+ detail = getattr(error, "detail", None) or {"message": str(error)}
1264
+ invalid = settings.invalid
1265
+ if isinstance(invalid, dict):
1266
+ invalid = {**invalid, "detail": detail}
1267
+ return settings.invalid_status, invalid, None
1268
+ return status, answer, extra
1269
+
1270
+ @staticmethod
1271
+ def _answer(
1272
+ op: OperationSettings,
1273
+ operation: Operation,
1274
+ ctx: ApiContext,
1275
+ args: dict[str, str],
1276
+ query: dict[str, str],
1277
+ body: Any,
1278
+ handlers: dict[str, Handler],
1279
+ ) -> Any:
1280
+ if op.action == "handler":
1281
+ return handlers[operation.name](ctx, args, query, body)
1282
+ if not operation.entity:
1283
+ return 501, {"error": "not_modelled", "operation": operation.name}
1284
+ entity = operation.entity
1285
+ values = {**{k: v for k, v in query.items() if k in operation.params}, **args}
1286
+ if op.action == "create":
1287
+ row = ctx.insert(entity, _bind(op, ctx, body))
1288
+ return op.status, row if op.single else wrap([row], ctx.settings, op.envelope)
1289
+ rows = [row for row in ctx.rows(entity) if _matches(row, op.filter, values, op.match)]
1290
+ if op.action in ("update", "delete"):
1291
+ if len(rows) != 1:
1292
+ return ctx.not_found()
1293
+ key = ctx.key_of(entity, rows[0])
1294
+ if op.action == "delete":
1295
+ ctx.delete(entity, key)
1296
+ return op.status, None
1297
+ row = ctx.update(entity, key, _bind(op, ctx, body, current=rows[0]))
1298
+ return op.status, row if op.single else wrap([row], ctx.settings, op.envelope)
1299
+ if op.single:
1300
+ if not rows:
1301
+ return ctx.not_found()
1302
+ return op.status, rows[0]
1303
+ paging = (
1304
+ ctx.settings.paging
1305
+ if op.paging is None
1306
+ else Paging(
1307
+ op.paging,
1308
+ ctx.settings.paging.limit_param,
1309
+ ctx.settings.paging.offset_param,
1310
+ ctx.settings.paging.cursor_param,
1311
+ ctx.settings.paging.page_size,
1312
+ )
1313
+ )
1314
+ page = paginate(rows, query, paging)
1315
+ return (
1316
+ op.status,
1317
+ wrap(page, ctx.settings, op.envelope, paging=paging, link=ctx.link),
1318
+ page_headers(page, ctx.settings, paging),
1319
+ )
1320
+
1321
+
1322
+ def send_response(
1323
+ handler: BaseHTTPRequestHandler, status: int, body: Any, extra: dict[str, str] | None, error_content_type: str
1324
+ ) -> None:
1325
+ """Write one ``WorldApp`` answer to a request handler's socket."""
1326
+ if status == 204 or body is None:
1327
+ handler.send_response(status)
1328
+ for name, value in (extra or {}).items():
1329
+ handler.send_header(name, value)
1330
+ handler.send_header("Content-Length", "0")
1331
+ handler.end_headers()
1332
+ return
1333
+ payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
1334
+ handler.send_response(status)
1335
+ handler.send_header("Content-Type", error_content_type if status >= 400 else "application/json")
1336
+ for name, value in (extra or {}).items():
1337
+ handler.send_header(name, value)
1338
+ handler.send_header("Content-Length", str(len(payload)))
1339
+ handler.end_headers()
1340
+ handler.wfile.write(payload)
1341
+
1342
+
1343
+ def make_handler(state: WorldState, router: Router, key: str | None):
1344
+ app = WorldApp(state, router, key)
1345
+
1346
+ class Handler(BaseHTTPRequestHandler):
1347
+ server_version = f"gateway-world-api/{router.spec.slug}"
1348
+
1349
+ def log_message(self, *_):
1350
+ pass
1351
+
1352
+ def _serve(self, method: str) -> None:
1353
+ length = int(self.headers.get("Content-Length") or 0)
1354
+ raw = self.rfile.read(length) if length else b""
1355
+ headers = {k.lower(): v for k, v in self.headers.items()}
1356
+ status, body, extra = app.handle(method, self.path, headers, raw, f"127.0.0.1:{self.server.server_port}")
1357
+ send_response(self, status, body, extra, app.settings.error_content_type)
1358
+
1359
+ def do_GET(self):
1360
+ self._serve("GET")
1361
+
1362
+ def do_POST(self):
1363
+ self._serve("POST")
1364
+
1365
+ def do_PUT(self):
1366
+ self._serve("PUT")
1367
+
1368
+ def do_PATCH(self):
1369
+ self._serve("PATCH")
1370
+
1371
+ def do_DELETE(self):
1372
+ self._serve("DELETE")
1373
+
1374
+ return Handler
1375
+
1376
+
1377
+ def serve(
1378
+ world: str | Path,
1379
+ *,
1380
+ port: int = PORT,
1381
+ host: str = "0.0.0.0",
1382
+ api_key: str | None = None,
1383
+ scenario: str | None = None,
1384
+ ) -> ThreadingHTTPServer:
1385
+ """Start the world's API and return the server; the caller decides how long it runs."""
1386
+ root = Path(world)
1387
+ spec = load_spec(root)
1388
+ settings = load_api_settings(root, spec)
1389
+ state = WorldState(root)
1390
+ if scenario:
1391
+ state.apply_scenario(scenario)
1392
+ server = ThreadingHTTPServer((host, port), make_handler(state, Router(spec, settings), api_key))
1393
+ server.world_state = state # type: ignore[attr-defined]
1394
+ return server
1395
+
1396
+
1397
+ def fetch_hosted_snapshot(world: str | Path, environ: dict[str, str] | None = None) -> dict | None:
1398
+ """The hosted pod's snapshot, when ``WORLD_SNAPSHOT_URL`` / ``_SHA256`` / ``_DIGEST``
1399
+ are set: downloaded to ``db/state.sqlite``, verified, sidecar written. All three
1400
+ are required together; a digest mismatch is fatal and names the digest."""
1401
+ env = os.environ if environ is None else environ
1402
+ values = {name: env.get(name) for name in (snapshots.ENV_URL, snapshots.ENV_SHA256, snapshots.ENV_DIGEST)}
1403
+ if not any(values.values()):
1404
+ return None
1405
+ missing = [name for name, value in values.items() if not value]
1406
+ if missing:
1407
+ fail("snapshot", f"{', '.join(missing)} must be set together with the snapshot URL")
1408
+ root = Path(world)
1409
+ return snapshots.fetch(
1410
+ root,
1411
+ check_bundle(root),
1412
+ values[snapshots.ENV_URL],
1413
+ values[snapshots.ENV_SHA256],
1414
+ values[snapshots.ENV_DIGEST],
1415
+ )
1416
+
1417
+
1418
+ def serve_forever(
1419
+ world: str | Path, *, port: int = PORT, api_key: str | None = None, scenario: str | None = None
1420
+ ) -> None:
1421
+ try:
1422
+ fetched = fetch_hosted_snapshot(world)
1423
+ except ContractError as error:
1424
+ print(f"world snapshot refused: {error}", file=sys.stderr)
1425
+ raise SystemExit(2)
1426
+ if fetched:
1427
+ print(f"world snapshot {fetched['batchDigest']} opened", file=sys.stderr)
1428
+ server = serve(world, port=port, api_key=api_key, scenario=scenario)
1429
+ try:
1430
+ print(f"world api listening on http://0.0.0.0:{port}", file=sys.stderr)
1431
+ server.serve_forever()
1432
+ finally:
1433
+ server.server_close()
1434
+ server.world_state.close() # type: ignore[attr-defined]
1435
+
1436
+
1437
+ # ---------------------------------------------------------------- bundle
1438
+
1439
+
1440
+ def openapi_document(spec: Spec, contract_id: str, settings: ApiSettings | None = None) -> dict:
1441
+ parameters = {"header": "header", "query": "query"}
1442
+ security_scheme: dict[str, Any] = {}
1443
+ if spec.scheme in ("header", "query"):
1444
+ security_scheme = {"type": "apiKey", "in": parameters[spec.scheme], "name": spec.auth_name}
1445
+ elif spec.scheme == "bearer":
1446
+ security_scheme = {"type": "http", "scheme": "bearer"}
1447
+ elif spec.scheme == "basic":
1448
+ security_scheme = {"type": "http", "scheme": "basic"}
1449
+ elif spec.scheme == "oauth2":
1450
+ token_url = (settings.token_path if settings else None) or DEFAULT_TOKEN_PATH
1451
+ security_scheme = {
1452
+ "type": "oauth2",
1453
+ "flows": {"clientCredentials": {"tokenUrl": token_url, "scopes": {spec.scope or "api": "API access"}}},
1454
+ }
1455
+ paths: dict[str, dict] = {}
1456
+ for operation in spec.operations.values():
1457
+ op = settings.for_operation(operation.name) if settings else OperationSettings()
1458
+ params = [
1459
+ {"name": name, "in": "path", "required": True, "schema": {"type": "string"}}
1460
+ for name in operation.placeholders
1461
+ ] + [
1462
+ {"name": name, "in": "query", "required": False, "schema": {"type": "string"}} for name in operation.params
1463
+ ]
1464
+ entry: dict[str, Any] = {
1465
+ "operationId": operation.name,
1466
+ "parameters": params,
1467
+ **({"x-gateway-entity": operation.entity} if operation.entity else {}),
1468
+ "x-gateway-action": op.action,
1469
+ "responses": {
1470
+ str(op.status): {"description": "OK"},
1471
+ "401": {"description": "Unauthorized"},
1472
+ "404": {"description": "Not found"},
1473
+ },
1474
+ **({"security": [{"vendor": []}]} if security_scheme else {}),
1475
+ }
1476
+ if op.action in ("create", "update") or (
1477
+ op.action == "handler" and operation.method in ("POST", "PUT", "PATCH")
1478
+ ):
1479
+ entry["requestBody"] = {"content": {"application/json": {"schema": {"type": "object"}}}}
1480
+ methods = paths.setdefault(operation.path, {})
1481
+ method = operation.method.lower()
1482
+ if method in methods:
1483
+ # OpenAPI keys one operation per path+method; a vendor that tells lookups apart
1484
+ # by query parameter keeps every one of them here rather than losing all but the first.
1485
+ methods[method].setdefault("x-gateway-variants", []).append(
1486
+ {
1487
+ k: v
1488
+ for k, v in entry.items()
1489
+ if k in ("operationId", "parameters", "x-gateway-entity", "x-gateway-action")
1490
+ }
1491
+ )
1492
+ else:
1493
+ methods[method] = entry
1494
+ document: dict[str, Any] = {
1495
+ "openapi": "3.1.0",
1496
+ "info": {"title": spec.slug, "version": "1.0.0", "x-gateway-domain": contract_id},
1497
+ "servers": [{"url": spec.base_url}],
1498
+ "paths": paths,
1499
+ }
1500
+ if security_scheme:
1501
+ document["components"] = {"securitySchemes": {"vendor": security_scheme}}
1502
+ return document
1503
+
1504
+
1505
+ def _scenario_dirs(root: Path) -> dict[str, Path]:
1506
+ """A world's authored scenarios: each a starting state (seed overlays replacing whole
1507
+ entities) plus an instruction, so a session can open the world mid-investigation,
1508
+ quota-exhausted, or whatever else the vendor's client should be tested against."""
1509
+ return snapshots.scenario_dirs(root)
1510
+
1511
+
1512
+ def base_seed(initial: dict) -> dict[str, list]:
1513
+ """``seed/base.json``: the initial batch's inserted rows by entity, what older
1514
+ runners post as the base of a session's seed."""
1515
+ seed: dict[str, list] = {}
1516
+ for mutation in initial.get("mutations", []):
1517
+ if mutation.get("op") == "insert":
1518
+ seed.setdefault(mutation["entity"], []).append(mutation["value"])
1519
+ return seed
1520
+
1521
+
1522
+ def write_base_seed(world: Path, bundle: Path) -> Path:
1523
+ """Regenerate the bundle's ``seed/base.json`` from the world's ``data/initial.json``."""
1524
+ target = bundle / "seed/base.json"
1525
+ target.parent.mkdir(parents=True, exist_ok=True)
1526
+ target.write_text(json.dumps(base_seed(read_json(world / "data/initial.json")), indent=1) + "\n")
1527
+ return target
1528
+
1529
+
1530
+ def build_api_bundle(world: str | Path, out: str | Path) -> Path:
1531
+ """Write the api-kind bundle that hosts this world's API under ``out``.
1532
+
1533
+ The world stays the authority; this is packaging. `gateway bench push` calls it
1534
+ for any schema world carrying connector.toml, so users push the world, not a
1535
+ second artifact."""
1536
+ root = Path(world)
1537
+ out_dir = Path(out)
1538
+ if out_dir.exists() and any(out_dir.iterdir()):
1539
+ fail("api", "Output directory must be empty or absent", "/api/out")
1540
+ spec = load_spec(root)
1541
+ settings = load_api_settings(root, spec)
1542
+ load_handlers(settings) # a handler the module lacks fails here, not in the container
1543
+ contract = compile_bundle(root)
1544
+ check_bundle(root)
1545
+ out_dir.mkdir(parents=True, exist_ok=True)
1546
+ manifest = [
1547
+ "# Generated from the world under server/world, which is the authority.",
1548
+ "[service]",
1549
+ f'name = "{contract.metadata["package"]["id"]}"',
1550
+ f'emulates = "{spec.slug}"',
1551
+ "",
1552
+ "[server]",
1553
+ f"port = {PORT}",
1554
+ f'healthcheck = "{ADMIN["health"]}"',
1555
+ "",
1556
+ "[admin]",
1557
+ f'reset = "{ADMIN["reset"]}"',
1558
+ f'seed = "{ADMIN["seed"]}"',
1559
+ f'state = "{ADMIN["state"]}"',
1560
+ "",
1561
+ ]
1562
+ if settings.ui is not None:
1563
+ # The built ui travels inside the copied world; the host serves it from
1564
+ # there, so no ui image is ever built for a schema world.
1565
+ manifest += [
1566
+ "[ui]",
1567
+ f'static = "server/world/{settings.ui.static}"',
1568
+ f'entry = "{settings.ui.entry}"',
1569
+ f'api_path = "{settings.ui.api_path}"',
1570
+ "",
1571
+ ]
1572
+ (out_dir / "gateway-api-world.toml").write_text("\n".join(manifest))
1573
+ (out_dir / "openapi.json").write_text(
1574
+ json.dumps(openapi_document(spec, contract.target["id"], settings), indent=1) + "\n"
1575
+ )
1576
+ (out_dir / "seed").mkdir()
1577
+ write_base_seed(root, out_dir)
1578
+ authored = _scenario_dirs(root)
1579
+ for name, path in authored.items():
1580
+ shutil.copytree(path, out_dir / "scenarios" / name)
1581
+ if "default" not in authored:
1582
+ scenario = out_dir / "scenarios/default"
1583
+ scenario.mkdir(parents=True)
1584
+ (scenario / "scenario.toml").write_text('name = "default"\nseed = "seed"\n')
1585
+ (scenario / "instruction.md").write_text(
1586
+ f"The {spec.slug} world serving the vendor's API, seeded with its captured state. Point the provider client here.\n"
1587
+ )
1588
+ server = out_dir / "server"
1589
+ package = server / "gatewaysdk" / "environment" / "schema"
1590
+ package.mkdir(parents=True)
1591
+ (server / "gatewaysdk" / "__init__.py").write_text("")
1592
+ (server / "gatewaysdk" / "environment" / "__init__.py").write_text("")
1593
+ for module in sorted(Path(__file__).parent.glob("*.py")):
1594
+ shutil.copy(module, package / module.name)
1595
+ shutil.copytree(root, server / "world", ignore=shutil.ignore_patterns("captures", "__pycache__", ".git"))
1596
+ (server / "main.py").write_text(
1597
+ "\n".join(
1598
+ [
1599
+ '"""Serve the world\'s API; everything it needs sits beside this file."""',
1600
+ "import os",
1601
+ "import sys",
1602
+ "from pathlib import Path",
1603
+ "",
1604
+ "sys.path.insert(0, str(Path(__file__).resolve().parent))",
1605
+ "from gatewaysdk.environment.schema.api import serve_forever # noqa: E402",
1606
+ "",
1607
+ "serve_forever(",
1608
+ ' Path(__file__).resolve().parent / "world",',
1609
+ f' port=int(os.environ.get("PORT") or {PORT}),',
1610
+ ' api_key=os.environ.get("WORLD_API_KEY") or None,',
1611
+ ")",
1612
+ "",
1613
+ ]
1614
+ )
1615
+ )
1616
+ (server / "Dockerfile").write_text(
1617
+ "\n".join(
1618
+ [
1619
+ "FROM python:3.12-slim",
1620
+ "WORKDIR /app",
1621
+ "COPY server /app",
1622
+ f"EXPOSE {PORT}",
1623
+ 'CMD ["python", "/app/main.py"]',
1624
+ "",
1625
+ ]
1626
+ )
1627
+ )
1628
+ return out_dir