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.
Files changed (245) hide show
  1. {cmpnd-0.8.2 → cmpnd-0.8.4}/CLAUDE.md +53 -14
  2. {cmpnd-0.8.2 → cmpnd-0.8.4}/PKG-INFO +1 -1
  3. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/_gepa_patch.py +7 -3
  4. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/admin.py +12 -0
  5. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/evals.py +6 -2
  6. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/top.py +8 -2
  7. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/traces.py +86 -3
  8. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/shell.py +4 -1
  9. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/deployment.py +204 -21
  10. cmpnd-0.8.4/cmpnd/exporter_http.py +43 -0
  11. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/models.py +2 -2
  12. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimization.py +211 -35
  13. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/gepa.py +8 -5
  14. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/tracker.py +33 -12
  15. cmpnd-0.8.4/cmpnd/retry.py +33 -0
  16. {cmpnd-0.8.2 → cmpnd-0.8.4}/docs/sdk-instrumentation.md +79 -9
  17. cmpnd-0.8.4/ingest/README.md +44 -0
  18. cmpnd-0.8.4/ingest/cmpnd_ingest/__init__.py +8 -0
  19. {cmpnd-0.8.2/cmpnd → cmpnd-0.8.4/ingest/cmpnd_ingest}/exporter_http.py +294 -186
  20. cmpnd-0.8.4/ingest/cmpnd_ingest/protocols.py +84 -0
  21. cmpnd-0.8.4/ingest/cmpnd_ingest/retry.py +186 -0
  22. cmpnd-0.8.4/ingest/pyproject.toml +46 -0
  23. {cmpnd-0.8.2 → cmpnd-0.8.4}/pyproject.toml +35 -4
  24. {cmpnd-0.8.2 → cmpnd-0.8.4}/scripts/capture_review_run.py +10 -3
  25. {cmpnd-0.8.2 → cmpnd-0.8.4}/scripts/seed_review_run.py +36 -23
  26. cmpnd-0.8.4/tests/_fake_clock.py +31 -0
  27. cmpnd-0.8.4/tests/ingest_conformance.py +330 -0
  28. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/test_logs_renders_traces.py +1 -1
  29. cmpnd-0.8.4/tests/parity/test_optimizations.py +121 -0
  30. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_traces.py +72 -1
  31. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_multi_client_stress.py +14 -8
  32. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_reliability_classify.py +44 -0
  33. cmpnd-0.8.4/tests/test_cli/__init__.py +0 -0
  34. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_admin.py +8 -0
  35. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_evals.py +6 -2
  36. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_shell.py +4 -1
  37. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_top.py +1 -1
  38. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_traces.py +3 -1
  39. cmpnd-0.8.4/tests/test_cli/test_traces_search_wire.py +186 -0
  40. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_deploy.py +208 -0
  41. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_eval_start.py +0 -2
  42. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_exporter.py +139 -100
  43. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_import_weight.py +49 -0
  44. cmpnd-0.8.4/tests/test_ingest_conformance.py +442 -0
  45. cmpnd-0.8.4/tests/test_optimization_start.py +273 -0
  46. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_optimize.py +354 -3
  47. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_optimize_terminal_failure.py +57 -12
  48. cmpnd-0.8.4/tests/test_retry.py +113 -0
  49. cmpnd-0.8.4/tests/test_schemas.py +224 -0
  50. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_seed_review_run.py +4 -4
  51. {cmpnd-0.8.2 → cmpnd-0.8.4}/uv.lock +1 -1
  52. cmpnd-0.8.2/tests/parity/test_optimizations.py +0 -87
  53. cmpnd-0.8.2/tests/test_optimization_start.py +0 -93
  54. cmpnd-0.8.2/tests/test_schemas.py +0 -132
  55. {cmpnd-0.8.2 → cmpnd-0.8.4}/.gitignore +0 -0
  56. {cmpnd-0.8.2 → cmpnd-0.8.4}/README.md +0 -0
  57. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/__init__.py +0 -0
  58. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/_program_patch.py +0 -0
  59. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/_rlm_patch.py +0 -0
  60. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/callback.py +0 -0
  61. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/__init__.py +0 -0
  62. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/api_client.py +0 -0
  63. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/__init__.py +0 -0
  64. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/auth.py +0 -0
  65. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/datasets.py +0 -0
  66. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/deployments.py +0 -0
  67. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/login.py +0 -0
  68. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/optimizations.py +0 -0
  69. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/org.py +0 -0
  70. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/commands/programs.py +0 -0
  71. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/credentials.py +0 -0
  72. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/trace_render.py +0 -0
  73. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/cli/verbs.py +0 -0
  74. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/configuration.py +0 -0
  75. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/context.py +0 -0
  76. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/dataset_sync.py +0 -0
  77. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/datasets.py +0 -0
  78. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/decorators.py +0 -0
  79. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/eval_handler.py +0 -0
  80. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/execution.py +0 -0
  81. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/exporter.py +0 -0
  82. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/helpers.py +0 -0
  83. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/identity.py +0 -0
  84. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/imaging.py +0 -0
  85. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/__init__.py +0 -0
  86. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/decode.py +0 -0
  87. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/encode.py +0 -0
  88. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/hash.py +0 -0
  89. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/program.py +0 -0
  90. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/reflection.py +0 -0
  91. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/types.py +0 -0
  92. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/ir/xxh64.py +0 -0
  93. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/__init__.py +0 -0
  94. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/gepa_callback.py +0 -0
  95. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/optimizers/resume.py +0 -0
  96. {cmpnd-0.8.2 → cmpnd-0.8.4}/cmpnd/packaging.py +0 -0
  97. {cmpnd-0.8.2 → cmpnd-0.8.4}/docs/2026-04-02-code-review.md +0 -0
  98. {cmpnd-0.8.2 → cmpnd-0.8.4}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  99. {cmpnd-0.8.2 → cmpnd-0.8.4}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  100. {cmpnd-0.8.2 → cmpnd-0.8.4}/examples/local_ollama.py +0 -0
  101. /cmpnd-0.8.2/stubs/dspy/utils/__init__.pyi → /cmpnd-0.8.4/ingest/cmpnd_ingest/py.typed +0 -0
  102. {cmpnd-0.8.2 → cmpnd-0.8.4}/scripts/fixtures/review_run.json.gz +0 -0
  103. {cmpnd-0.8.2 → cmpnd-0.8.4}/scripts/gepa_resume_bug.py +0 -0
  104. {cmpnd-0.8.2 → cmpnd-0.8.4}/stubs/dspy/__init__.pyi +0 -0
  105. {cmpnd-0.8.2 → cmpnd-0.8.4}/stubs/dspy/primitives/__init__.pyi +0 -0
  106. {cmpnd-0.8.2 → cmpnd-0.8.4}/stubs/dspy/primitives/prediction.pyi +0 -0
  107. /cmpnd-0.8.2/tests/e2e/__init__.py → /cmpnd-0.8.4/stubs/dspy/utils/__init__.pyi +0 -0
  108. {cmpnd-0.8.2 → cmpnd-0.8.4}/stubs/dspy/utils/callback.pyi +0 -0
  109. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/__init__.py +0 -0
  110. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/_exporter_helpers.py +0 -0
  111. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/_identity_helpers.py +0 -0
  112. {cmpnd-0.8.2/tests/integration → cmpnd-0.8.4/tests/e2e}/__init__.py +0 -0
  113. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/committee_task.py +0 -0
  114. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/conftest.py +0 -0
  115. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  116. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  117. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/fake_lm.py +0 -0
  118. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/helpers.py +0 -0
  119. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_capture_prompts.py +0 -0
  120. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_composite_module.py +0 -0
  121. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_dataset_auto_creation.py +0 -0
  122. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_deploy_execute.py +0 -0
  123. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_deploy_programs.py +0 -0
  124. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_edge_cases.py +0 -0
  125. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_example_hash.py +0 -0
  126. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_gepa_optimization.py +0 -0
  127. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_lm_accuracy.py +0 -0
  128. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_module_tracing.py +0 -0
  129. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimization_lineage.py +0 -0
  130. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimization_metric_identity.py +0 -0
  131. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimize_failures.py +0 -0
  132. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimize_loop.py +0 -0
  133. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimize_review_committee.py +0 -0
  134. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  135. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_parallel_export.py +0 -0
  136. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_project_attribution.py +0 -0
  137. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_react_tool_spans.py +0 -0
  138. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_save_load.py +0 -0
  139. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_trace_structure.py +0 -0
  140. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/e2e/test_user_attribution.py +0 -0
  141. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/01_predict_qa.json +0 -0
  142. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  143. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  144. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  145. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  146. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  147. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/07_program_of_thought.json +0 -0
  148. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/08_opaque_module.json +0 -0
  149. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  150. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/10_react.json +0 -0
  151. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/11_refine.json +0 -0
  152. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/12_retrieve.json +0 -0
  153. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/13_knn.json +0 -0
  154. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/14_parallel.json +0 -0
  155. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/identity_vectors/README.md +0 -0
  156. {cmpnd-0.8.2/tests/parity → cmpnd-0.8.4/tests/integration}/__init__.py +0 -0
  157. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/conftest.py +0 -0
  158. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  159. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/test_optimizing_progress.py +0 -0
  160. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/integration/test_unhappy_path.py +0 -0
  161. {cmpnd-0.8.2/tests/stress → cmpnd-0.8.4/tests/parity}/__init__.py +0 -0
  162. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/conftest.py +0 -0
  163. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/seed_parity.py +0 -0
  164. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_auth.py +0 -0
  165. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_datasets.py +0 -0
  166. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_deployments.py +0 -0
  167. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_evals.py +0 -0
  168. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_health.py +0 -0
  169. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_modules.py +0 -0
  170. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_programs.py +0 -0
  171. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_signatures.py +0 -0
  172. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/parity/test_trace_streaming.py +0 -0
  173. {cmpnd-0.8.2/tests/test_cli → cmpnd-0.8.4/tests/stress}/__init__.py +0 -0
  174. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/_provider.py +0 -0
  175. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/_reliability.py +0 -0
  176. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/mint_bedrock_token.py +0 -0
  177. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_deploy_programs.py +0 -0
  178. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_optimize_client_committee.py +0 -0
  179. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_optimize_loop.py +0 -0
  180. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_optimize_loop_wasm.py +0 -0
  181. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_optimize_stress.py +0 -0
  182. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_provider_roles.py +0 -0
  183. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_real_provider_twins.py +0 -0
  184. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_reliability.py +0 -0
  185. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/stress/test_rlm_wasm.py +0 -0
  186. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_callback.py +0 -0
  187. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_candidate_lineage.py +0 -0
  188. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/conftest.py +0 -0
  189. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_api_client.py +0 -0
  190. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_auth.py +0 -0
  191. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_credentials.py +0 -0
  192. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_datasets.py +0 -0
  193. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_deployments.py +0 -0
  194. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_login.py +0 -0
  195. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_main.py +0 -0
  196. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_optimizations.py +0 -0
  197. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_org.py +0 -0
  198. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_parity.py +0 -0
  199. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cli/test_programs.py +0 -0
  200. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/__init__.py +0 -0
  201. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/conftest.py +0 -0
  202. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/test_edge_cases.py +0 -0
  203. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/test_hash_consistency.py +0 -0
  204. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_clustering/test_type_conversion.py +0 -0
  205. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_cmp551_grouping_identity.py +0 -0
  206. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_configuration.py +0 -0
  207. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_context.py +0 -0
  208. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_context_propagation.py +0 -0
  209. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_dataset_polling.py +0 -0
  210. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_dataset_sync.py +0 -0
  211. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_datasets.py +0 -0
  212. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_decorators.py +0 -0
  213. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_eval.py +0 -0
  214. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_exception_paths.py +0 -0
  215. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_execute.py +0 -0
  216. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_exporter_real.py +0 -0
  217. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_fake_lm.py +0 -0
  218. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_gepa_callback.py +0 -0
  219. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_gepa_integration.py +0 -0
  220. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_gepa_resume.py +0 -0
  221. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_identity.py +0 -0
  222. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_imaging.py +0 -0
  223. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_integration.py +0 -0
  224. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_ir_encoder_properties.py +0 -0
  225. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_ir_golden_vectors.py +0 -0
  226. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_ir_roundtrip.py +0 -0
  227. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_models.py +0 -0
  228. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_optimization_progress.py +0 -0
  229. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_optimize_lift.py +0 -0
  230. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_packaging.py +0 -0
  231. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_packaging_callable_source.py +0 -0
  232. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_packaging_program_source.py +0 -0
  233. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_program_lineage_persist.py +0 -0
  234. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_project_payload.py +0 -0
  235. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_reflector_properties.py +0 -0
  236. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_reflector_smoke.py +0 -0
  237. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_repro_reactv2_bugs.py +0 -0
  238. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_rlm_patch.py +0 -0
  239. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_stats_export.py +0 -0
  240. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_trace_render.py +0 -0
  241. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_tracked_gepa.py +0 -0
  242. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_tracker_enrich.py +0 -0
  243. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_truncation.py +0 -0
  244. {cmpnd-0.8.2 → cmpnd-0.8.4}/tests/test_xxh64.py +0 -0
  245. {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), the `cmpnd` package ships to PyPI:
8
- `python-sdk/cmpnd/**` is **public source**. Anything written there is
9
- world-readable in the published wheel. So **never put private information in the
10
- shipped package** — no internal issue-tracker IDs (`CMP-…`), internal URLs or
11
- dashboards, customer/org names, infra hostnames, or secrets — and that includes
12
- comments, docstrings, `--help`/CLI strings, error messages, and log lines, not
13
- just code. Reference an internal ticket in the **commit message or PR
14
- description** (those live in the private repo), never in the source that ships.
15
-
16
- This applies only to the shipped package (`python-sdk/cmpnd/`). Tests
17
- (`python-sdk/tests/`), `docs/`, and `docs/plans/` are not published in the
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
- the package. When you cite prior work in shipped code, describe the behavior,
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`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmpnd
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: DSPy observability and deployment SDK for cmpnd
5
5
  Project-URL: Homepage, https://cmpnd.ai
6
6
  Author-email: cmpnd <hello@cmpnd.ai>
@@ -149,12 +149,16 @@ def patch_gepa() -> None:
149
149
 
150
150
  trk.capture_compiled_program(result)
151
151
 
152
- # Tag compiled program with optimization run ID
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
- body: dict[str, Any] = {"page": args.page, "per_page": args.per_page}
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", "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
- return f"cmpnd traces search --deployment {short}"
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, "per_page": 1},
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("--from", dest="start_time_from", help="Start time from (ISO 8601)")
30
- search.add_argument("--to", dest="start_time_to", help="Start time to (ISO 8601)")
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
- body: dict[str, Any] = {"page": args.page, "per_page": args.per_page}
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, "per_page": 200},
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=30.0) as client:
213
- # Step 1: Create deployment via the platform API
214
- create_resp = client.post(
215
- f"{config.endpoint}/api/v1/deployments",
216
- json=deploy_body,
217
- headers=api_headers,
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
- upload_resp = client.put(
239
- upload_url,
240
- content=zip_bytes,
241
- headers={"Content-Type": "application/zip"},
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
- start = time.time()
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
- if time.time() - start > timeout:
249
- raise TimeoutError(f"Deployment {deployment_id} not ready after {timeout}s. Last status: polling.")
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
- time.sleep(2)
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