cmpnd 0.8.2__tar.gz → 0.8.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cmpnd-0.8.2 → cmpnd-0.8.4}/CLAUDE.md +53 -14
- {cmpnd-0.8.2 → cmpnd-0.8.4}/PKG-INFO +1 -1
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/_gepa_patch.py +7 -3
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/admin.py +12 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/evals.py +6 -2
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/top.py +8 -2
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/traces.py +86 -3
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/shell.py +4 -1
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/deployment.py +204 -21
- cmpnd-0.8.4/cmpnd/exporter_http.py +43 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/models.py +2 -2
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimization.py +211 -35
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/gepa.py +8 -5
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/tracker.py +33 -12
- cmpnd-0.8.4/cmpnd/retry.py +33 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/docs/sdk-instrumentation.md +79 -9
- cmpnd-0.8.4/ingest/README.md +44 -0
- cmpnd-0.8.4/ingest/cmpnd_ingest/__init__.py +8 -0
- {cmpnd-0.8.2/cmpnd → cmpnd-0.8.4/ingest/cmpnd_ingest}/exporter_http.py +294 -186
- cmpnd-0.8.4/ingest/cmpnd_ingest/protocols.py +84 -0
- cmpnd-0.8.4/ingest/cmpnd_ingest/retry.py +186 -0
- cmpnd-0.8.4/ingest/pyproject.toml +46 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/pyproject.toml +35 -4
- {cmpnd-0.8.2 → cmpnd-0.8.4}/scripts/capture_review_run.py +10 -3
- {cmpnd-0.8.2 → cmpnd-0.8.4}/scripts/seed_review_run.py +36 -23
- cmpnd-0.8.4/tests/_fake_clock.py +31 -0
- cmpnd-0.8.4/tests/ingest_conformance.py +330 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/test_logs_renders_traces.py +1 -1
- cmpnd-0.8.4/tests/parity/test_optimizations.py +121 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_traces.py +72 -1
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_multi_client_stress.py +14 -8
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_reliability_classify.py +44 -0
- cmpnd-0.8.4/tests/test_cli/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_admin.py +8 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_evals.py +6 -2
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_shell.py +4 -1
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_top.py +1 -1
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_traces.py +3 -1
- cmpnd-0.8.4/tests/test_cli/test_traces_search_wire.py +186 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_deploy.py +208 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_eval_start.py +0 -2
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_exporter.py +139 -100
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_import_weight.py +49 -0
- cmpnd-0.8.4/tests/test_ingest_conformance.py +442 -0
- cmpnd-0.8.4/tests/test_optimization_start.py +273 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_optimize.py +354 -3
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_optimize_terminal_failure.py +57 -12
- cmpnd-0.8.4/tests/test_retry.py +113 -0
- cmpnd-0.8.4/tests/test_schemas.py +224 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_seed_review_run.py +4 -4
- {cmpnd-0.8.2 → cmpnd-0.8.4}/uv.lock +1 -1
- cmpnd-0.8.2/tests/parity/test_optimizations.py +0 -87
- cmpnd-0.8.2/tests/test_optimization_start.py +0 -93
- cmpnd-0.8.2/tests/test_schemas.py +0 -132
- {cmpnd-0.8.2 → cmpnd-0.8.4}/.gitignore +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/README.md +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/_program_patch.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/_rlm_patch.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/callback.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/api_client.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/auth.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/datasets.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/deployments.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/login.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/optimizations.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/org.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/programs.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/credentials.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/trace_render.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/verbs.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/configuration.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/context.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/dataset_sync.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/datasets.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/decorators.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/eval_handler.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/execution.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/exporter.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/helpers.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/identity.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/imaging.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/decode.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/encode.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/hash.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/program.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/reflection.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/types.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/xxh64.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/gepa_callback.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/resume.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/packaging.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/docs/2026-04-02-code-review.md +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/examples/local_ollama.py +0 -0
- /cmpnd-0.8.2/stubs/dspy/utils/__init__.pyi → /cmpnd-0.8.4/ingest/cmpnd_ingest/py.typed +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/scripts/fixtures/review_run.json.gz +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/scripts/gepa_resume_bug.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/stubs/dspy/__init__.pyi +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/stubs/dspy/primitives/__init__.pyi +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/stubs/dspy/primitives/prediction.pyi +0 -0
- /cmpnd-0.8.2/tests/e2e/__init__.py → /cmpnd-0.8.4/stubs/dspy/utils/__init__.pyi +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/stubs/dspy/utils/callback.pyi +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/_exporter_helpers.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/_identity_helpers.py +0 -0
- {cmpnd-0.8.2/tests/integration → cmpnd-0.8.4/tests/e2e}/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/committee_task.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/conftest.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/data/committee_train.ndjson.gz +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/data/committee_val.ndjson.gz +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/fake_lm.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/helpers.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_capture_prompts.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_composite_module.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_dataset_auto_creation.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_deploy_execute.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_deploy_programs.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_edge_cases.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_example_hash.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_gepa_optimization.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_lm_accuracy.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_module_tracing.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimization_lineage.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimization_metric_identity.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimize_failures.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimize_loop.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimize_review_committee.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_parallel_export.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_project_attribution.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_react_tool_spans.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_save_load.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_trace_structure.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_user_attribution.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/01_predict_qa.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/04_predict_richer_types.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/06_multichaincomparison.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/07_program_of_thought.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/08_opaque_module.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/10_react.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/11_refine.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/12_retrieve.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/13_knn.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/14_parallel.json +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/README.md +0 -0
- {cmpnd-0.8.2/tests/parity → cmpnd-0.8.4/tests/integration}/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/conftest.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/test_cli_deploy_smoketest.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/test_optimizing_progress.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/test_unhappy_path.py +0 -0
- {cmpnd-0.8.2/tests/stress → cmpnd-0.8.4/tests/parity}/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/conftest.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/seed_parity.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_auth.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_datasets.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_deployments.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_evals.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_health.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_modules.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_programs.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_signatures.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_trace_streaming.py +0 -0
- {cmpnd-0.8.2/tests/test_cli → cmpnd-0.8.4/tests/stress}/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/_provider.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/_reliability.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/mint_bedrock_token.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_deploy_programs.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_optimize_client_committee.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_optimize_loop.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_optimize_loop_wasm.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_optimize_stress.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_provider_roles.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_real_provider_twins.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_reliability.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_rlm_wasm.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_callback.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_candidate_lineage.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/conftest.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_api_client.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_auth.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_credentials.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_datasets.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_deployments.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_login.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_main.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_optimizations.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_org.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_parity.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_programs.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/__init__.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/conftest.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/test_edge_cases.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/test_hash_consistency.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/test_type_conversion.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cmp551_grouping_identity.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_configuration.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_context.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_context_propagation.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_dataset_polling.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_dataset_sync.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_datasets.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_decorators.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_eval.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_exception_paths.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_execute.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_exporter_real.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_fake_lm.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_gepa_callback.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_gepa_integration.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_gepa_resume.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_identity.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_imaging.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_integration.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_ir_encoder_properties.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_ir_golden_vectors.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_ir_roundtrip.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_models.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_optimization_progress.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_optimize_lift.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_packaging.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_packaging_callable_source.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_packaging_program_source.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_program_lineage_persist.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_project_payload.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_reflector_properties.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_reflector_smoke.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_repro_reactv2_bugs.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_rlm_patch.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_stats_export.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_trace_render.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_tracked_gepa.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_tracker_enrich.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_truncation.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_xxh64.py +0 -0
- {cmpnd-0.8.2 → cmpnd-0.8.4}/todo.md +0 -0
|
@@ -4,20 +4,27 @@ Part of the CMPND monorepo. See `../CLAUDE.md` for cross-cutting rules (hash arc
|
|
|
4
4
|
|
|
5
5
|
## This code is released publicly — no private references
|
|
6
6
|
|
|
7
|
-
Unlike the rest of the monorepo (private),
|
|
8
|
-
`python-sdk/cmpnd/**`
|
|
9
|
-
world-readable in
|
|
10
|
-
shipped
|
|
11
|
-
dashboards, customer/org names, infra hostnames,
|
|
12
|
-
comments, docstrings, `--help`/CLI strings, error
|
|
13
|
-
just code. Reference an internal ticket in the
|
|
14
|
-
description** (those live in the private repo), never in
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
Unlike the rest of the monorepo (private), this component ships to PyPI:
|
|
8
|
+
`python-sdk/cmpnd/**` and `python-sdk/ingest/cmpnd_ingest/**` are **public
|
|
9
|
+
source**. Anything written there is world-readable in a published wheel. So
|
|
10
|
+
**never put private information in shipped source** — no internal issue-tracker
|
|
11
|
+
IDs (`CMP-…`), internal URLs or dashboards, customer/org names, infra hostnames,
|
|
12
|
+
or secrets — and that includes comments, docstrings, `--help`/CLI strings, error
|
|
13
|
+
messages, and log lines, not just code. Reference an internal ticket in the
|
|
14
|
+
**commit message or PR description** (those live in the private repo), never in
|
|
15
|
+
the source that ships.
|
|
16
|
+
|
|
17
|
+
The test is not which directory a file sits in but **whether a wheel carries
|
|
18
|
+
it**. `ingest/cmpnd_ingest` is published twice over: as the `cmpnd-ingest`
|
|
19
|
+
distribution built from `ingest/pyproject.toml`, and inside the `cmpnd` wheel,
|
|
20
|
+
which *bundles* rather than depends on it (`[tool.hatch.build.targets.wheel]`
|
|
21
|
+
`packages`). A directory that is not `cmpnd/` is therefore not exempt — if a
|
|
22
|
+
future distribution is added here, it inherits this rule on the same test.
|
|
23
|
+
|
|
24
|
+
Tests (`python-sdk/tests/`), `docs/`, and `docs/plans/` are not published in any
|
|
18
25
|
wheel, but prefer keeping issue IDs out of docstrings you might later move into
|
|
19
|
-
|
|
20
|
-
not the ticket.
|
|
26
|
+
a shipped package. When you cite prior work in shipped code, describe the
|
|
27
|
+
behavior, not the ticket.
|
|
21
28
|
|
|
22
29
|
## Documentation discipline (docs are as-built, updated in the same commit)
|
|
23
30
|
|
|
@@ -45,8 +52,40 @@ uv run ruff check . # lint
|
|
|
45
52
|
uv run ruff format --check . # format check
|
|
46
53
|
```
|
|
47
54
|
|
|
55
|
+
## Two packages, one published distribution
|
|
56
|
+
|
|
57
|
+
`cmpnd` (`cmpnd/`) is the SDK. `cmpnd_ingest` (`ingest/cmpnd_ingest/`) is the
|
|
58
|
+
ingest transport — `exporter_http.py`, `retry.py`, and the `Protocol`s stating
|
|
59
|
+
what a producer's records must look like. `cmpnd` re-exports it, so
|
|
60
|
+
`cmpnd.exporter_http` and `cmpnd.retry` are the *same module objects* as
|
|
61
|
+
`cmpnd_ingest.exporter_http` and `cmpnd_ingest.retry` (a `sys.modules` rebinding,
|
|
62
|
+
not a copy — see the reasoning at the top of `cmpnd/exporter_http.py`).
|
|
63
|
+
|
|
64
|
+
The transport sits under its own directory, with its own `pyproject.toml`
|
|
65
|
+
(`cmpnd-ingest`), so a producer that is not a DSPy program can install it without
|
|
66
|
+
dspy: its entire dependency set is httpx. The wasm plane's native GEPA child
|
|
67
|
+
(`worker-wasm/optimizer/`) is that producer, and a venv unable to supply dspy is
|
|
68
|
+
what enforces its boundary. `ingest/README.md` has the full argument.
|
|
69
|
+
|
|
70
|
+
What that costs you, before touching either pyproject:
|
|
71
|
+
|
|
72
|
+
- **`cmpnd-ingest` is never published, and `cmpnd` must never depend on it.** A
|
|
73
|
+
`dependencies` entry would be a hard requirement on a name PyPI does not
|
|
74
|
+
carry — a `tool.uv.sources` entry does not travel in package metadata, so a
|
|
75
|
+
user's `pip install cmpnd` would see only the requirement. Instead `cmpnd`'s
|
|
76
|
+
wheel and sdist *carry* `cmpnd_ingest` (`[tool.hatch.build.targets.wheel]
|
|
77
|
+
packages`), so installing `cmpnd` by any means — pip or uv, wheel, sdist or
|
|
78
|
+
source tree — supplies the transport.
|
|
79
|
+
- **Consume it by path, and never alongside `cmpnd`.** Both distributions
|
|
80
|
+
provide the `cmpnd_ingest` package, so a venv holding both has two copies of
|
|
81
|
+
the transport's files and the winner is whichever was written last. A venv
|
|
82
|
+
installs `cmpnd` *or* `cmpnd-ingest` — `worker-wasm/optimizer/` is the only
|
|
83
|
+
consumer of the latter.
|
|
84
|
+
|
|
48
85
|
## Key Files
|
|
49
86
|
|
|
87
|
+
- `ingest/cmpnd_ingest/exporter_http.py` - The ingest transport (`cmpnd.exporter_http`)
|
|
88
|
+
- `ingest/cmpnd_ingest/retry.py` - The retryable-error contract's client half (`cmpnd.retry`)
|
|
50
89
|
- `cmpnd/identity.py` - Unified hash computations (xxhash, SHA256)
|
|
51
90
|
- `cmpnd/ir/` - Structural program-identity IR (algebra, encoder, hash, reflector)
|
|
52
91
|
- `cmpnd/callback.py` - DSPy instrumentation, signature extraction
|
|
@@ -130,4 +169,4 @@ When you touch a handler:
|
|
|
130
169
|
|
|
131
170
|
## Release
|
|
132
171
|
|
|
133
|
-
Tag-free. Bump `[project] version` in `pyproject.toml` (`uv version --bump {minor,patch}` or by hand), open a PR, merge to `main`. A change to that version on `main` triggers `.github/workflows/release-sdk.yml`, which publishes to PyPI only when the version is new (build → TestPyPI → PyPI); a merge that doesn't bump the version no-ops. Runtime `cmpnd.__version__` reads installed metadata. Full flow: [`docs/releasing.md`](../docs/releasing.md) Part 3 and `docs/plans/2026-07-02-sdk-pypi-tag-free-publish.md`.
|
|
172
|
+
Tag-free. Bump `[project] version` in `pyproject.toml` (`uv version --bump {minor,patch}` or by hand), open a PR, merge to `main`. A change to that version on `main` triggers `.github/workflows/release-sdk.yml`, which publishes to PyPI only when the version is new (build → TestPyPI → PyPI); a merge that doesn't bump the version no-ops. `cmpnd` is the only distribution that ships from here — the transport rides inside its wheel, and `ingest/pyproject.toml`'s version names nothing on any index. Runtime `cmpnd.__version__` reads installed metadata. Full flow: [`docs/releasing.md`](../docs/releasing.md) Part 3 and `docs/plans/2026-07-02-sdk-pypi-tag-free-publish.md`.
|
|
@@ -149,12 +149,16 @@ def patch_gepa() -> None:
|
|
|
149
149
|
|
|
150
150
|
trk.capture_compiled_program(result)
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
trk.finalize()
|
|
153
|
+
|
|
154
|
+
# Tag compiled program with optimization run ID. **After finalize**,
|
|
155
|
+
# because that is the last point at which the run can acquire one: a
|
|
156
|
+
# tracker that never start()ed reserves its id on the terminal flush,
|
|
157
|
+
# and tagging first would stamp the program with "None" for a run that
|
|
158
|
+
# does exist — breaking the link this tag is here to create.
|
|
153
159
|
if hasattr(result, "__dict__"):
|
|
154
160
|
result._optimization_run_id = str(trk.optimization_run_id)
|
|
155
161
|
|
|
156
|
-
trk.finalize()
|
|
157
|
-
|
|
158
162
|
return result
|
|
159
163
|
|
|
160
164
|
# `BaseException`, because the invariant does not get to have an exception: a
|
|
@@ -116,6 +116,10 @@ def _register_user(sub: _SubParsersAction[ArgumentParser]) -> None:
|
|
|
116
116
|
enable.add_argument("id", help="User id")
|
|
117
117
|
enable.set_defaults(func=handle_user_enable)
|
|
118
118
|
|
|
119
|
+
delete = usub.add_parser("delete", help="Hard-delete a disabled user (disable it first)")
|
|
120
|
+
delete.add_argument("id", help="User id")
|
|
121
|
+
delete.set_defaults(func=handle_user_delete)
|
|
122
|
+
|
|
119
123
|
reset_password = usub.add_parser("reset-password", help="Set a user's password")
|
|
120
124
|
reset_password.add_argument("id", help="User id")
|
|
121
125
|
reset_password.add_argument("--password", required=True, help="New password")
|
|
@@ -247,6 +251,14 @@ def handle_user_enable(client: api_client.ApiClient, args: Any) -> Any:
|
|
|
247
251
|
return {"enabled": args.id}
|
|
248
252
|
|
|
249
253
|
|
|
254
|
+
def handle_user_delete(client: api_client.ApiClient, args: Any) -> Any:
|
|
255
|
+
# Hard-delete via the JSON API (the teardown primitive for the e2e suite).
|
|
256
|
+
# Disabled-first is enforced server-side: disable the account before delete.
|
|
257
|
+
# 204 No Content → delete() returns None; synthesize a confirmation.
|
|
258
|
+
client.delete(f"/api/v1/admin/users/{args.id}")
|
|
259
|
+
return {"deleted": args.id}
|
|
260
|
+
|
|
261
|
+
|
|
250
262
|
def handle_user_reset_password(client: api_client.ApiClient, args: Any) -> Any:
|
|
251
263
|
client.post(f"/api/v1/admin/users/{args.id}/reset-password", json={"password": args.password})
|
|
252
264
|
return {"password_reset": args.id}
|
|
@@ -26,10 +26,14 @@ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def handle_search(client: api_client.ApiClient, args: Any) -> Any:
|
|
29
|
-
|
|
29
|
+
# The right-hand names are the API's, and they are not all the flag's name:
|
|
30
|
+
# `--per-page` posts page_size and `--metric` posts metric. An unread key
|
|
31
|
+
# raises no error on either side of the wire, so both flags silently did
|
|
32
|
+
# nothing while the mapping disagreed.
|
|
33
|
+
body: dict[str, Any] = {"page": args.page, "page_size": args.per_page}
|
|
30
34
|
for attr, key in [
|
|
31
35
|
("program_name", "program_name"),
|
|
32
|
-
("metric_name", "
|
|
36
|
+
("metric_name", "metric"),
|
|
33
37
|
("status", "status"),
|
|
34
38
|
]:
|
|
35
39
|
val = getattr(args, attr, None)
|
|
@@ -200,7 +200,10 @@ def _hint_traces_search(deployment_id: str) -> str:
|
|
|
200
200
|
# in the REPL fetches and prints `<Registry n=...>`; tab-completion
|
|
201
201
|
# under it enumerates traces.
|
|
202
202
|
return f"deps.{_attr_name(deployment_id)}.traces"
|
|
203
|
-
|
|
203
|
+
# --all, because the count this hint sits under was itself computed over all
|
|
204
|
+
# history: without it the suggested command searches only the route's
|
|
205
|
+
# default window and would print a smaller number than the one above it.
|
|
206
|
+
return f"cmpnd traces search --all --deployment {short}"
|
|
204
207
|
|
|
205
208
|
|
|
206
209
|
# --------------------------------------------------------------------------
|
|
@@ -496,8 +499,11 @@ def _print_trace_summary(client: api_client.ApiClient, deployment_id: str) -> No
|
|
|
496
499
|
"""
|
|
497
500
|
try:
|
|
498
501
|
resp = client.post(
|
|
502
|
+
# all_time: a deployment's traces are interesting however old the
|
|
503
|
+
# deployment is, and the route otherwise applies a default window
|
|
504
|
+
# that would report 0 for anything last run a while ago.
|
|
499
505
|
"/api/v1/traces/search",
|
|
500
|
-
json={"deployment_id": deployment_id, "
|
|
506
|
+
json={"deployment_id": deployment_id, "page_size": 1, "all_time": True},
|
|
501
507
|
)
|
|
502
508
|
except api_client.CliError as exc:
|
|
503
509
|
print(_c(f"Traces: (lookup failed: {exc})", "dim"))
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import re
|
|
6
7
|
import sys
|
|
7
8
|
import time
|
|
8
9
|
from argparse import ArgumentParser, _SubParsersAction
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
9
11
|
from typing import Any
|
|
10
12
|
|
|
11
13
|
from .. import api_client, trace_render
|
|
@@ -26,8 +28,24 @@ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
|
26
28
|
action="append",
|
|
27
29
|
help="Filter by project tag (the sidebar 'Tags' filter); repeat for any-of",
|
|
28
30
|
)
|
|
29
|
-
search.add_argument(
|
|
30
|
-
|
|
31
|
+
search.add_argument(
|
|
32
|
+
"--from",
|
|
33
|
+
dest="start_time_from",
|
|
34
|
+
help="Earliest start time: ISO 8601, or a duration back from now (30d, 2w, 12h, 90m). "
|
|
35
|
+
"Without it the server applies its own default window",
|
|
36
|
+
)
|
|
37
|
+
search.add_argument(
|
|
38
|
+
"--to",
|
|
39
|
+
dest="start_time_to",
|
|
40
|
+
help="Latest start time: ISO 8601, or a duration back from now (see --from). "
|
|
41
|
+
"Requires --from (or --all): the server refuses an upper bound on its own",
|
|
42
|
+
)
|
|
43
|
+
search.add_argument(
|
|
44
|
+
"--all",
|
|
45
|
+
dest="all_time",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Search all retained history instead of the default window (cannot be combined with --from/--to)",
|
|
48
|
+
)
|
|
31
49
|
search.add_argument("--page", type=int, default=1)
|
|
32
50
|
search.add_argument("--per-page", type=int, default=50)
|
|
33
51
|
search.set_defaults(func=handle_search)
|
|
@@ -57,8 +75,49 @@ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
|
57
75
|
follow.set_defaults(func=handle_follow)
|
|
58
76
|
|
|
59
77
|
|
|
78
|
+
_RELATIVE_TIME = re.compile(r"^(\d+)([mhdw])$")
|
|
79
|
+
|
|
80
|
+
_RELATIVE_UNITS = {
|
|
81
|
+
"m": "minutes",
|
|
82
|
+
"h": "hours",
|
|
83
|
+
"d": "days",
|
|
84
|
+
"w": "weeks",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_search_time(value: str) -> str:
|
|
89
|
+
"""Resolve a search bound to an ISO 8601 timestamp.
|
|
90
|
+
|
|
91
|
+
A duration (``30d``, ``2w``, ``12h``, ``90m``) means that far back from now;
|
|
92
|
+
anything else passes through for the server to parse. The sugar lives here
|
|
93
|
+
rather than in the API so the wire stays timestamp-only — one grammar for
|
|
94
|
+
every client, resolved once at the edge that knows what "now" means to the
|
|
95
|
+
user.
|
|
96
|
+
"""
|
|
97
|
+
m = _RELATIVE_TIME.match(value.strip())
|
|
98
|
+
if not m:
|
|
99
|
+
return value
|
|
100
|
+
amount, unit = int(m.group(1)), m.group(2)
|
|
101
|
+
when = datetime.now(timezone.utc) - timedelta(**{_RELATIVE_UNITS[unit]: amount})
|
|
102
|
+
return when.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
103
|
+
|
|
104
|
+
|
|
60
105
|
def handle_search(client: api_client.ApiClient, args: Any) -> Any:
|
|
61
|
-
|
|
106
|
+
all_time = bool(getattr(args, "all_time", False))
|
|
107
|
+
if all_time and (getattr(args, "start_time_from", None) or getattr(args, "start_time_to", None)):
|
|
108
|
+
raise api_client.CliError("--all searches all history; drop --from/--to, or drop --all")
|
|
109
|
+
# An upper bound needs a lower one. The route refuses the pair (only a lower
|
|
110
|
+
# bound lets it read a bounded slice of history), so say so here in the
|
|
111
|
+
# flags the user typed rather than relaying a message about JSON fields.
|
|
112
|
+
if getattr(args, "start_time_to", None) and not getattr(args, "start_time_from", None):
|
|
113
|
+
raise api_client.CliError("--to needs a lower bound too: add --from (e.g. --from 30d), or --all for everything")
|
|
114
|
+
|
|
115
|
+
# page_size, not per_page: the API's key. They differed for a while, and
|
|
116
|
+
# because an unread key raises no error on either side, --per-page simply
|
|
117
|
+
# did nothing.
|
|
118
|
+
body: dict[str, Any] = {"page": args.page, "page_size": args.per_page}
|
|
119
|
+
if all_time:
|
|
120
|
+
body["all_time"] = True
|
|
62
121
|
for attr, key in [
|
|
63
122
|
("status", "status"),
|
|
64
123
|
("program_name", "program_name"),
|
|
@@ -71,8 +130,32 @@ def handle_search(client: api_client.ApiClient, args: Any) -> Any:
|
|
|
71
130
|
val = getattr(args, attr, None)
|
|
72
131
|
if val is not None:
|
|
73
132
|
body[key] = val
|
|
133
|
+
for key in ("start_time_from", "start_time_to"):
|
|
134
|
+
if key in body:
|
|
135
|
+
body[key] = resolve_search_time(str(body[key]))
|
|
74
136
|
resp = client.post("/api/v1/traces/search", json=body)
|
|
75
137
|
|
|
138
|
+
# A window the caller did not ask for has to be visible: read as an org
|
|
139
|
+
# figure, a windowed total is simply wrong, and nothing in the numbers says
|
|
140
|
+
# so. The bound comes from the response, so this reports the window actually
|
|
141
|
+
# applied rather than restating a default this side only assumes.
|
|
142
|
+
if resp.get("default_window_applied") and (lower := resp.get("start_time_from")):
|
|
143
|
+
print(
|
|
144
|
+
f"note: no time filter given, so results cover traces since {lower} only "
|
|
145
|
+
f"(total and total_cost are for that window). Widen with --from 30d, or --all for everything.",
|
|
146
|
+
file=sys.stderr,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# The server names the body keys it did not understand. Left unreported they
|
|
150
|
+
# look exactly like a filter that worked: a plausible result set that
|
|
151
|
+
# answered a different question.
|
|
152
|
+
if ignored := resp.get("ignored_fields"):
|
|
153
|
+
print(
|
|
154
|
+
f"warning: this server ignored request fields {', '.join(ignored)} — "
|
|
155
|
+
"those filters had no effect. Check the CLI and server versions match.",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
|
|
76
159
|
# Servers that predate the deployment_id filter silently drop the
|
|
77
160
|
# unknown key and return whole-org traces. If the user asked for a
|
|
78
161
|
# deployment filter and we got back traces with no deployment_id
|
|
@@ -441,8 +441,11 @@ class Deployment(Entity):
|
|
|
441
441
|
|
|
442
442
|
def _fetch(client: api_client.ApiClient) -> list[dict[str, Any]]:
|
|
443
443
|
resp = client.post(
|
|
444
|
+
# all_time: this registry is scoped to one deployment, so the
|
|
445
|
+
# route's default window would hide the traces of anything not
|
|
446
|
+
# run recently.
|
|
444
447
|
"/api/v1/traces/search",
|
|
445
|
-
json={"deployment_id": deployment_id, "
|
|
448
|
+
json={"deployment_id": deployment_id, "page_size": 200, "all_time": True},
|
|
446
449
|
)
|
|
447
450
|
return resp.get("traces", [])
|
|
448
451
|
|
|
@@ -12,14 +12,39 @@ The deploy flow:
|
|
|
12
12
|
import json
|
|
13
13
|
import logging
|
|
14
14
|
import time
|
|
15
|
+
from collections.abc import Callable
|
|
15
16
|
from typing import Any
|
|
16
17
|
|
|
17
18
|
import httpx
|
|
18
19
|
|
|
19
|
-
from . import configuration, execution, identity, models, packaging
|
|
20
|
+
from . import configuration, execution, identity, models, packaging, retry
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
22
23
|
|
|
24
|
+
# httpx applies a timeout per request *phase*, not cumulatively, so each of these
|
|
25
|
+
# bounds one phase of one attempt.
|
|
26
|
+
#
|
|
27
|
+
# Connect is held short on every call: a connection that was never established is
|
|
28
|
+
# the one failure the create POST may safely re-send (below), so reaching that
|
|
29
|
+
# verdict quickly is worth more than waiting out a slow connect.
|
|
30
|
+
_CONNECT_TIMEOUT_SECONDS = 5.0
|
|
31
|
+
|
|
32
|
+
# The read budget for the steps a re-send is safe on. Short on purpose — for a
|
|
33
|
+
# call we can simply repeat, a blip is better noticed and ridden through than
|
|
34
|
+
# waited out.
|
|
35
|
+
_TIMEOUT_SECONDS = 30.0
|
|
36
|
+
|
|
37
|
+
# The create POST gets its own, longer, read budget because it is the one step
|
|
38
|
+
# that cannot be retried out of a slow answer: a read timeout there may mean the
|
|
39
|
+
# deployment was created and only the answer was lost, so waiting is the only
|
|
40
|
+
# tolerance available to it. A server under load can legitimately take longer to
|
|
41
|
+
# answer than the budget the repeatable steps run on, and a deploy aborted at
|
|
42
|
+
# that line was progressing fine.
|
|
43
|
+
_CREATE_TIMEOUT_SECONDS = 120.0
|
|
44
|
+
|
|
45
|
+
# How long the readiness poll waits between status reads.
|
|
46
|
+
_POLL_INTERVAL_SECONDS = 2.0
|
|
47
|
+
|
|
23
48
|
# The deploy metadata blob is a free-form JSON object committed with
|
|
24
49
|
# the deployment record (the checkpoint/cursor that produced the program). Cap
|
|
25
50
|
# its size — a checkpoint/cursor is a pointer, not a payload. Mirrors serve's
|
|
@@ -80,6 +105,82 @@ def _raise_http_error(response: httpx.Response, action: str) -> None:
|
|
|
80
105
|
raise RuntimeError(msg)
|
|
81
106
|
|
|
82
107
|
|
|
108
|
+
def _repeatable_answer(response: httpx.Response) -> bool:
|
|
109
|
+
"""Whether a call that is safe to repeat should simply ask again.
|
|
110
|
+
|
|
111
|
+
Wider than the platform's ``retryable`` contract (``cmpnd.retry``) on
|
|
112
|
+
purpose. That contract exists so a client can decide about re-sending a
|
|
113
|
+
*write* it has no other way to reason about; a call that can be repeated
|
|
114
|
+
with no consequence needs no such permission from the server, so the whole
|
|
115
|
+
5xx class counts here — including a bare 500, which the contract leaves
|
|
116
|
+
alone precisely because it cannot tell whether the work landed.
|
|
117
|
+
"""
|
|
118
|
+
return response.status_code >= 500
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _wait_before_retry(tally: retry.TransientTally, response: httpx.Response | None) -> bool:
|
|
122
|
+
"""Wait out the backoff, or report that the budget is spent."""
|
|
123
|
+
now = time.time()
|
|
124
|
+
if tally.exhausted(now):
|
|
125
|
+
return False
|
|
126
|
+
time.sleep(retry.delay_for(tally.count - 1, response))
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _send_retrying(
|
|
131
|
+
send: Callable[[], httpx.Response],
|
|
132
|
+
action: str,
|
|
133
|
+
*,
|
|
134
|
+
may_resend_with_no_answer: Callable[[BaseException], bool],
|
|
135
|
+
may_resend_after_answer: Callable[[httpx.Response], bool],
|
|
136
|
+
) -> httpx.Response:
|
|
137
|
+
"""Run one deploy call, riding through the failures the caller says it may.
|
|
138
|
+
|
|
139
|
+
Returns the first answer that is not worth re-sending — successful or not,
|
|
140
|
+
so the caller still reads the status itself through ``_raise_http_error``.
|
|
141
|
+
Backoff, budget and ``Retry-After`` handling are the shared policy in
|
|
142
|
+
``cmpnd.retry``.
|
|
143
|
+
|
|
144
|
+
The two predicates are separate because the two failure modes carry
|
|
145
|
+
different information: an answer says the server acted, while no answer at
|
|
146
|
+
all leaves that unknowable, and a call whose re-send would mint a duplicate
|
|
147
|
+
can only tolerate the subset that rules the first attempt out.
|
|
148
|
+
|
|
149
|
+
Tolerating is never silent: a survived blip warns, and so does a budget that
|
|
150
|
+
ran out. An exhausted budget still hands back the last answer rather than
|
|
151
|
+
raising over it, so a deploy that ultimately fails on a server error reads
|
|
152
|
+
exactly as it would have without any retrying — what it endured on the way
|
|
153
|
+
is on the record, not in place of the error the caller has to act on. Only a
|
|
154
|
+
failure with no answer at all has nothing to hand back, and raises here.
|
|
155
|
+
"""
|
|
156
|
+
tally = retry.TransientTally()
|
|
157
|
+
while True:
|
|
158
|
+
try:
|
|
159
|
+
response = send()
|
|
160
|
+
except httpx.RequestError as exc:
|
|
161
|
+
if not may_resend_with_no_answer(exc):
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"Failed to {action}: no answer from the server ({type(exc).__name__}: {exc})."
|
|
164
|
+
"\n Not re-sent: with no answer it is unknowable whether the request was acted on."
|
|
165
|
+
) from exc
|
|
166
|
+
tally.record(type(exc).__name__, at=time.time(), sample=str(exc))
|
|
167
|
+
if not _wait_before_retry(tally, None):
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"Failed to {action}: gave up after {tally.describe(time.time())}. Last: {exc}"
|
|
170
|
+
) from exc
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
if response.is_success or not may_resend_after_answer(response):
|
|
174
|
+
if tally.count:
|
|
175
|
+
logger.warning("deploy: rode through %s to %s", tally.describe(time.time()), action)
|
|
176
|
+
return response
|
|
177
|
+
|
|
178
|
+
tally.record(str(response.status_code), at=time.time(), sample=response.text)
|
|
179
|
+
if not _wait_before_retry(tally, response):
|
|
180
|
+
logger.warning("deploy: gave up after %s trying to %s", tally.describe(time.time()), action)
|
|
181
|
+
return response
|
|
182
|
+
|
|
183
|
+
|
|
83
184
|
class DeployFailed(RuntimeError):
|
|
84
185
|
"""Raised when a deployment transitions to ``status=failed``.
|
|
85
186
|
|
|
@@ -134,7 +235,14 @@ def deploy(module, metric=None, timeout=300, metadata=None):
|
|
|
134
235
|
Args:
|
|
135
236
|
module: A DSPy module (e.g. dspy.Predict, dspy.ChainOfThought).
|
|
136
237
|
metric: Optional metric callable for optimization support.
|
|
137
|
-
timeout: Seconds to wait for the program to become ready (default 300)
|
|
238
|
+
timeout: Seconds to wait for the program to become ready (default 300),
|
|
239
|
+
measured from the moment the upload completes. A transient blip
|
|
240
|
+
while waiting — a lost connection, a slow answer, a 5xx — is ridden
|
|
241
|
+
through inside this budget rather than aborting the deploy, so the
|
|
242
|
+
wait has no second, smaller retry budget that can run out first.
|
|
243
|
+
Packaging, the create call and the upload run before that clock
|
|
244
|
+
starts and are bounded by their own per-request timeouts, so the
|
|
245
|
+
total time deploy() blocks is those plus this.
|
|
138
246
|
metadata: Optional free-form structured dict committed atomically with
|
|
139
247
|
the deployment record — somewhere to put the checkpoint/cursor that
|
|
140
248
|
produced this program, so the deployment is self-describing for a
|
|
@@ -209,12 +317,28 @@ def deploy(module, metric=None, timeout=300, metadata=None):
|
|
|
209
317
|
"Content-Type": "application/json",
|
|
210
318
|
}
|
|
211
319
|
|
|
212
|
-
with httpx.Client(timeout=
|
|
213
|
-
# Step 1: Create deployment via the platform API
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
320
|
+
with httpx.Client(timeout=httpx.Timeout(_TIMEOUT_SECONDS, connect=_CONNECT_TIMEOUT_SECONDS)) as client:
|
|
321
|
+
# Step 1: Create deployment via the platform API.
|
|
322
|
+
#
|
|
323
|
+
# **This POST is not idempotent** — it allocates a deployment row — so it
|
|
324
|
+
# is re-sent only on a failure that proves the request never reached the
|
|
325
|
+
# server: a connection that was never established (`ConnectError` and the
|
|
326
|
+
# DNS/connect-phase failures under it), and nothing else. Not a read
|
|
327
|
+
# timeout, and not a 5xx: either may mean the server processed the
|
|
328
|
+
# request and only the answer was lost, and re-sending on that guess is
|
|
329
|
+
# how one deploy mints two deployments. That asymmetry with the poll loop
|
|
330
|
+
# below — which retries freely because a GET can always be repeated — is
|
|
331
|
+
# the whole design here.
|
|
332
|
+
create_resp = _send_retrying(
|
|
333
|
+
lambda: client.post(
|
|
334
|
+
f"{config.endpoint}/api/v1/deployments",
|
|
335
|
+
json=deploy_body,
|
|
336
|
+
headers=api_headers,
|
|
337
|
+
timeout=httpx.Timeout(_CREATE_TIMEOUT_SECONDS, connect=_CONNECT_TIMEOUT_SECONDS),
|
|
338
|
+
),
|
|
339
|
+
"create deployment",
|
|
340
|
+
may_resend_with_no_answer=retry.never_left_the_client,
|
|
341
|
+
may_resend_after_answer=lambda _: False,
|
|
218
342
|
)
|
|
219
343
|
_raise_http_error(create_resp, "create deployment")
|
|
220
344
|
data = create_resp.json()
|
|
@@ -235,29 +359,88 @@ def deploy(module, metric=None, timeout=300, metadata=None):
|
|
|
235
359
|
# direct to the data plane (the control plane never touches the payload),
|
|
236
360
|
# raw body, no multipart form. Content-Length matches the length we
|
|
237
361
|
# declared at create, so a size-bounded presigned PUT accepts it.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
362
|
+
#
|
|
363
|
+
# Unlike the create above, this one is idempotent by construction: the
|
|
364
|
+
# destination key is already pinned by the slot and the bytes are fixed,
|
|
365
|
+
# so a re-send overwrites the same object with the same content and no
|
|
366
|
+
# duplicate can exist. It therefore rides through a transient answer and
|
|
367
|
+
# a transport failure alike, whether or not the first attempt landed.
|
|
368
|
+
upload_resp = _send_retrying(
|
|
369
|
+
lambda: client.put(
|
|
370
|
+
upload_url,
|
|
371
|
+
content=zip_bytes,
|
|
372
|
+
headers={"Content-Type": "application/zip"},
|
|
373
|
+
),
|
|
374
|
+
"upload deployment ZIP",
|
|
375
|
+
may_resend_with_no_answer=lambda _: True,
|
|
376
|
+
may_resend_after_answer=_repeatable_answer,
|
|
242
377
|
)
|
|
243
378
|
_raise_http_error(upload_resp, "upload deployment ZIP")
|
|
244
379
|
|
|
245
|
-
# Step 3: Poll until ready
|
|
246
|
-
|
|
380
|
+
# Step 3: Poll until ready.
|
|
381
|
+
#
|
|
382
|
+
# A status read is idempotent, so a blip on it costs one poll interval
|
|
383
|
+
# and never the deployment: no answer at all, or a 5xx, means ask again
|
|
384
|
+
# on the next tick. The caller's own `timeout` is the only bound on that
|
|
385
|
+
# — there is no separate retry budget to run out, because giving up
|
|
386
|
+
# early on a deployment that is still progressing is the failure this
|
|
387
|
+
# loop exists to avoid. A 4xx still raises (the caller's problem), and so
|
|
388
|
+
# does `status == "failed"` (the deployment's).
|
|
389
|
+
deadline = time.time() + timeout
|
|
390
|
+
tally = retry.TransientTally()
|
|
391
|
+
reported = 0
|
|
247
392
|
while True:
|
|
248
|
-
|
|
249
|
-
|
|
393
|
+
now = time.time()
|
|
394
|
+
if now >= deadline:
|
|
395
|
+
msg = f"Deployment {deployment_id} not ready after {timeout}s. Last status: polling."
|
|
396
|
+
if tally.count:
|
|
397
|
+
msg += f" Rode through {tally.describe(now)}."
|
|
398
|
+
raise TimeoutError(msg)
|
|
399
|
+
|
|
400
|
+
# Both the pause and the read it precedes are clipped to what is
|
|
401
|
+
# left of the budget, so a poll issued just inside the deadline
|
|
402
|
+
# cannot answer well past it. Without the clip a status read
|
|
403
|
+
# starting one millisecond early could still block for the full
|
|
404
|
+
# read timeout, overshooting by that much every time. A clipped
|
|
405
|
+
# read that runs out is an ordinary no-answer-this-tick: it lands
|
|
406
|
+
# in the tally and the loop above turns it into the TimeoutError.
|
|
407
|
+
pause = min(_POLL_INTERVAL_SECONDS, deadline - now)
|
|
408
|
+
time.sleep(pause)
|
|
409
|
+
remaining = deadline - now - pause
|
|
410
|
+
if remaining <= 0:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
poll_resp = client.get(
|
|
415
|
+
f"{config.endpoint}/api/v1/deployments/{deployment_id}",
|
|
416
|
+
headers=api_headers,
|
|
417
|
+
timeout=httpx.Timeout(
|
|
418
|
+
min(_TIMEOUT_SECONDS, remaining),
|
|
419
|
+
connect=min(_CONNECT_TIMEOUT_SECONDS, remaining),
|
|
420
|
+
),
|
|
421
|
+
)
|
|
422
|
+
except httpx.RequestError as exc:
|
|
423
|
+
# Every transport failure, not a named subset: they all mean the
|
|
424
|
+
# same thing for a read — no answer this tick, ask again next.
|
|
425
|
+
tally.record(type(exc).__name__, at=time.time(), sample=str(exc))
|
|
426
|
+
continue
|
|
250
427
|
|
|
251
|
-
|
|
428
|
+
if not poll_resp.is_success and _repeatable_answer(poll_resp):
|
|
429
|
+
tally.record(str(poll_resp.status_code), at=time.time(), sample=poll_resp.text)
|
|
430
|
+
continue
|
|
252
431
|
|
|
253
|
-
poll_resp = client.get(
|
|
254
|
-
f"{config.endpoint}/api/v1/deployments/{deployment_id}",
|
|
255
|
-
headers=api_headers,
|
|
256
|
-
)
|
|
257
432
|
_raise_http_error(poll_resp, "poll deployment status")
|
|
258
433
|
status_data = poll_resp.json()
|
|
259
434
|
status = status_data["status"]
|
|
260
435
|
|
|
436
|
+
# The tally is cumulative over the whole poll — it is what the
|
|
437
|
+
# TimeoutError names if the deployment never arrives — so warn only
|
|
438
|
+
# about blips not already reported rather than on every poll after
|
|
439
|
+
# the first one.
|
|
440
|
+
if tally.count > reported:
|
|
441
|
+
logger.warning("deploy: rode through %s while polling deployment status", tally.describe(time.time()))
|
|
442
|
+
reported = tally.count
|
|
443
|
+
|
|
261
444
|
if status == "ready":
|
|
262
445
|
return execution.DeployedProgram(
|
|
263
446
|
deployment_id,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""``cmpnd.exporter_http`` **is** :mod:`cmpnd_ingest.exporter_http`.
|
|
2
|
+
|
|
3
|
+
The transport moved to its own distribution — `cmpnd-ingest`, whose package this
|
|
4
|
+
wheel bundles rather than depends on — so that a producer that is not a DSPy
|
|
5
|
+
program can install it without installing dspy. See `ingest/README.md` for why
|
|
6
|
+
this wheel carries the package instead of taking it as a dependency,
|
|
7
|
+
and `docs/plans/2026-08-11-execution-plane-ownership-and-tiers.md` for the
|
|
8
|
+
decision. This module keeps the name every existing caller imports.
|
|
9
|
+
|
|
10
|
+
**It aliases rather than re-exports**, and the difference is the point. Two
|
|
11
|
+
callers reach past this module's public names into it:
|
|
12
|
+
`python-sdk/scripts/capture_review_run.py` replaces ``_send_json`` to capture
|
|
13
|
+
every ingest call, and the SDK's own ``cmpnd.exporter.__getattr__`` forwards
|
|
14
|
+
arbitrary attribute reads here. A module that re-bound the public names would
|
|
15
|
+
leave both patching a copy — the replacement would land on this module while the
|
|
16
|
+
transport kept calling the original, silently. Rebinding ``sys.modules[__name__]``
|
|
17
|
+
instead makes the two names one module object, so ``cmpnd.exporter_http is
|
|
18
|
+
cmpnd_ingest.exporter_http`` and there is nothing to keep in sync.
|
|
19
|
+
|
|
20
|
+
The ``TYPE_CHECKING`` branch is for the checker alone, which cannot follow the
|
|
21
|
+
rebinding: it states the same identity in the only form a type checker reads.
|
|
22
|
+
Names are listed rather than star-imported so that ``X as X`` marks each as a
|
|
23
|
+
deliberate re-export.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from cmpnd_ingest.exporter_http import BatchExporter as BatchExporter
|
|
32
|
+
from cmpnd_ingest.exporter_http import ExportItem as ExportItem
|
|
33
|
+
from cmpnd_ingest.exporter_http import ExportRetriesExhausted as ExportRetriesExhausted
|
|
34
|
+
from cmpnd_ingest.exporter_http import _is_retryable_export_error as _is_retryable_export_error
|
|
35
|
+
from cmpnd_ingest.exporter_http import _may_resend_a_minting_post as _may_resend_a_minting_post
|
|
36
|
+
from cmpnd_ingest.exporter_http import _send_json as _send_json
|
|
37
|
+
from cmpnd_ingest.exporter_http import logger as logger
|
|
38
|
+
else:
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
from cmpnd_ingest import exporter_http as _impl
|
|
42
|
+
|
|
43
|
+
sys.modules[__name__] = _impl
|