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