cmpnd 0.55.0__tar.gz → 0.56.0__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 (384) hide show
  1. {cmpnd-0.55.0 → cmpnd-0.56.0}/PKG-INFO +1 -1
  2. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/programs.py +139 -0
  3. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/top.py +5 -0
  4. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/verbs.py +3 -0
  5. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/programs.py +1 -1
  6. {cmpnd-0.55.0 → cmpnd-0.56.0}/docs/cli.md +44 -2
  7. {cmpnd-0.55.0 → cmpnd-0.56.0}/docs/sdk-instrumentation.md +3 -0
  8. {cmpnd-0.55.0 → cmpnd-0.56.0}/pyproject.toml +1 -1
  9. cmpnd-0.56.0/tests/test_cli/test_programs.py +290 -0
  10. {cmpnd-0.55.0 → cmpnd-0.56.0}/uv.lock +1 -1
  11. cmpnd-0.55.0/tests/test_cli/test_programs.py +0 -143
  12. {cmpnd-0.55.0 → cmpnd-0.56.0}/.gitignore +0 -0
  13. {cmpnd-0.55.0 → cmpnd-0.56.0}/AGENTS.md +0 -0
  14. {cmpnd-0.55.0 → cmpnd-0.56.0}/CLAUDE.md +0 -0
  15. {cmpnd-0.55.0 → cmpnd-0.56.0}/README.md +0 -0
  16. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/__init__.py +0 -0
  17. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_assessments.py +0 -0
  18. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_authored_import.py +0 -0
  19. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_deployments.py +0 -0
  20. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_exporter_pool.py +0 -0
  21. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_gepa_patch.py +0 -0
  22. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_http.py +0 -0
  23. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_program_patch.py +0 -0
  24. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_rlm_patch.py +0 -0
  25. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_runtime_tools.py +0 -0
  26. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_timespec.py +0 -0
  27. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_traces.py +0 -0
  28. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/_valsets.py +0 -0
  29. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/authored.py +0 -0
  30. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/callback.py +0 -0
  31. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/__init__.py +0 -0
  32. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/api_client.py +0 -0
  33. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/__init__.py +0 -0
  34. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/admin.py +0 -0
  35. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/assessments.py +0 -0
  36. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/auth.py +0 -0
  37. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/datasets.py +0 -0
  38. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/deployments.py +0 -0
  39. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/evals.py +0 -0
  40. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/login.py +0 -0
  41. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/optimizations.py +0 -0
  42. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/org.py +0 -0
  43. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/provider_credentials.py +0 -0
  44. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/signatures.py +0 -0
  45. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/tool_servers.py +0 -0
  46. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/traces.py +0 -0
  47. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/commands/valsets.py +0 -0
  48. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/credentials.py +0 -0
  49. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/deprecation.py +0 -0
  50. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/pagination.py +0 -0
  51. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/shell.py +0 -0
  52. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/trace_render.py +0 -0
  53. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/cli/tree_help.py +0 -0
  54. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/client.py +0 -0
  55. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/configuration.py +0 -0
  56. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/context.py +0 -0
  57. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/dataset_sync.py +0 -0
  58. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/datasets.py +0 -0
  59. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/decorators.py +0 -0
  60. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/deployment.py +0 -0
  61. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/deployment_metadata.py +0 -0
  62. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/deployment_runs.py +0 -0
  63. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/eval_handler.py +0 -0
  64. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/execution.py +0 -0
  65. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/exporter.py +0 -0
  66. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/exporter_http.py +0 -0
  67. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/helpers.py +0 -0
  68. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/identity.py +0 -0
  69. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/imaging.py +0 -0
  70. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/ir/__init__.py +0 -0
  71. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/ir/decode.py +0 -0
  72. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/ir/encode.py +0 -0
  73. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/ir/hash.py +0 -0
  74. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/ir/program.py +0 -0
  75. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/ir/reflection.py +0 -0
  76. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/ir/types.py +0 -0
  77. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/ir/xxh64.py +0 -0
  78. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/lm_configuration.py +0 -0
  79. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/mcp_servers.py +0 -0
  80. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/models.py +0 -0
  81. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/named_programs.py +0 -0
  82. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/optimization.py +0 -0
  83. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/optimizers/__init__.py +0 -0
  84. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/optimizers/gepa.py +0 -0
  85. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/optimizers/gepa_callback.py +0 -0
  86. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/optimizers/resume.py +0 -0
  87. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/optimizers/tracker.py +0 -0
  88. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/packaging.py +0 -0
  89. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/references.py +0 -0
  90. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/retry.py +0 -0
  91. {cmpnd-0.55.0 → cmpnd-0.56.0}/cmpnd/traces.py +0 -0
  92. {cmpnd-0.55.0 → cmpnd-0.56.0}/docs/2026-04-02-code-review.md +0 -0
  93. {cmpnd-0.55.0 → cmpnd-0.56.0}/docs/client.md +0 -0
  94. {cmpnd-0.55.0 → cmpnd-0.56.0}/docs/custom-proposer.md +0 -0
  95. {cmpnd-0.55.0 → cmpnd-0.56.0}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  96. {cmpnd-0.55.0 → cmpnd-0.56.0}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  97. {cmpnd-0.55.0 → cmpnd-0.56.0}/docs/reading-traces.md +0 -0
  98. {cmpnd-0.55.0 → cmpnd-0.56.0}/examples/generalizing_proposer.py +0 -0
  99. {cmpnd-0.55.0 → cmpnd-0.56.0}/examples/local_ollama.py +0 -0
  100. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/LINEAGE.md +0 -0
  101. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/README.md +0 -0
  102. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/__init__.py +0 -0
  103. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/bodies.py +0 -0
  104. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/client_floor.py +0 -0
  105. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/exporter_http.py +0 -0
  106. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/program_names.py +0 -0
  107. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/protocols.py +0 -0
  108. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/py.typed +0 -0
  109. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/request_log.py +0 -0
  110. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/retry.py +0 -0
  111. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/cmpnd_ingest/trace_facts.py +0 -0
  112. {cmpnd-0.55.0 → cmpnd-0.56.0}/ingest/pyproject.toml +0 -0
  113. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/audit-sdist.sh +0 -0
  114. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/capture_review_run.py +0 -0
  115. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/check-pip-install.sh +0 -0
  116. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/check_release_artifacts.py +0 -0
  117. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/fixtures/review_run.json.gz +0 -0
  118. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/gepa_resume_bug.py +0 -0
  119. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/release-stress.sh +0 -0
  120. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/require_version_bump.py +0 -0
  121. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/seed_review_run.py +0 -0
  122. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/selfhost_compat.py +0 -0
  123. {cmpnd-0.55.0 → cmpnd-0.56.0}/scripts/test-in-linux.sh +0 -0
  124. {cmpnd-0.55.0 → cmpnd-0.56.0}/stubs/dspy/__init__.pyi +0 -0
  125. {cmpnd-0.55.0 → cmpnd-0.56.0}/stubs/dspy/primitives/__init__.pyi +0 -0
  126. {cmpnd-0.55.0 → cmpnd-0.56.0}/stubs/dspy/primitives/prediction.pyi +0 -0
  127. {cmpnd-0.55.0 → cmpnd-0.56.0}/stubs/dspy/utils/__init__.pyi +0 -0
  128. {cmpnd-0.55.0 → cmpnd-0.56.0}/stubs/dspy/utils/callback.pyi +0 -0
  129. {cmpnd-0.55.0 → cmpnd-0.56.0}/stubs/dspy/utils/dummies.pyi +0 -0
  130. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/__init__.py +0 -0
  131. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/_deployment_signature_helpers.py +0 -0
  132. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/_exporter_helpers.py +0 -0
  133. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/_fake_clock.py +0 -0
  134. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/_identity_helpers.py +0 -0
  135. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/_resolve_refs.py +0 -0
  136. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/_traced_run.py +0 -0
  137. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/conftest.py +0 -0
  138. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/__init__.py +0 -0
  139. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/committee_task.py +0 -0
  140. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/conftest.py +0 -0
  141. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  142. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  143. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/fake_lm.py +0 -0
  144. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/helpers.py +0 -0
  145. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/target_capabilities.py +0 -0
  146. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_assessments_cli.py +0 -0
  147. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_capture_prompts.py +0 -0
  148. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_composite_module.py +0 -0
  149. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_dataset_auto_creation.py +0 -0
  150. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_deploy_execute.py +0 -0
  151. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_deploy_pandas.py +0 -0
  152. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_deploy_programs.py +0 -0
  153. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_edge_cases.py +0 -0
  154. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_example_hash.py +0 -0
  155. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_gepa_optimization.py +0 -0
  156. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_interpreter_spans.py +0 -0
  157. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_lm_accuracy.py +0 -0
  158. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_module_tracing.py +0 -0
  159. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_optimization_lineage.py +0 -0
  160. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_optimization_metric_identity.py +0 -0
  161. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_optimize_failures.py +0 -0
  162. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_optimize_loop.py +0 -0
  163. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_optimize_review_committee.py +0 -0
  164. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  165. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_parallel_export.py +0 -0
  166. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_project_attribution.py +0 -0
  167. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_react_tool_spans.py +0 -0
  168. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_save_load.py +0 -0
  169. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_trace_structure.py +0 -0
  170. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_traces_read.py +0 -0
  171. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_user_attribution.py +0 -0
  172. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/e2e/test_valsets_cli.py +0 -0
  173. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/01_predict_qa.json +0 -0
  174. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  175. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  176. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  177. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  178. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  179. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/07_program_of_thought.json +0 -0
  180. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/08_opaque_module.json +0 -0
  181. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  182. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/10_react.json +0 -0
  183. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/11_refine.json +0 -0
  184. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/12_retrieve.json +0 -0
  185. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/13_knn.json +0 -0
  186. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/14_parallel.json +0 -0
  187. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/identity_vectors/README.md +0 -0
  188. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/ingest_conformance.py +0 -0
  189. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/__init__.py +0 -0
  190. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/authored_optimize/judge.py +0 -0
  191. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/authored_optimize/optimize_hosted.py +0 -0
  192. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/conftest.py +0 -0
  193. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/hosted_mcp_tool_optimize_probe.py +0 -0
  194. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/hosted_optimize_probe.py +0 -0
  195. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/optimization_run_probe.py +0 -0
  196. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  197. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/test_logs_renders_traces.py +0 -0
  198. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/test_optimization_run.py +0 -0
  199. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/test_optimizing_progress.py +0 -0
  200. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/integration/test_unhappy_path.py +0 -0
  201. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/__init__.py +0 -0
  202. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/__main__.py +0 -0
  203. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/drain.py +0 -0
  204. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/ingest.py +0 -0
  205. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/latency_transport.py +0 -0
  206. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/rlm_shape.py +0 -0
  207. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/rlm_size_report.py +0 -0
  208. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/sweep.py +0 -0
  209. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/test_rlm_shape.py +0 -0
  210. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/load/test_rlm_size_report.py +0 -0
  211. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/__init__.py +0 -0
  212. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/conftest.py +0 -0
  213. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/seed_parity.py +0 -0
  214. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_auth.py +0 -0
  215. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_datasets.py +0 -0
  216. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_deployments.py +0 -0
  217. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_evals.py +0 -0
  218. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_health.py +0 -0
  219. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_modules.py +0 -0
  220. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_optimizations.py +0 -0
  221. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_programs.py +0 -0
  222. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_signatures.py +0 -0
  223. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_trace_streaming.py +0 -0
  224. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/parity/test_traces.py +0 -0
  225. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/span_reference_fixture.py +0 -0
  226. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/__init__.py +0 -0
  227. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/_provider.py +0 -0
  228. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/_readiness.py +0 -0
  229. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/_reliability.py +0 -0
  230. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/_result.py +0 -0
  231. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/mint_bedrock_token.py +0 -0
  232. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_deploy_programs.py +0 -0
  233. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_multi_client_stress.py +0 -0
  234. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_optimize_client_committee.py +0 -0
  235. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_optimize_loop.py +0 -0
  236. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_optimize_loop_wasm.py +0 -0
  237. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_optimize_stress.py +0 -0
  238. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_provider_roles.py +0 -0
  239. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_readiness.py +0 -0
  240. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_real_provider_twins.py +0 -0
  241. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_reliability.py +0 -0
  242. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_reliability_classify.py +0 -0
  243. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_rlm_wasm.py +0 -0
  244. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/stress/test_trace_search.py +0 -0
  245. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_adapter_reentrancy.py +0 -0
  246. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_assessments_api.py +0 -0
  247. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_authored.py +0 -0
  248. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_authored_bundle_collisions.py +0 -0
  249. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_authored_metric_contract.py +0 -0
  250. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_authored_named_models.py +0 -0
  251. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_authored_optimize.py +0 -0
  252. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_callback.py +0 -0
  253. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_candidate_lineage.py +0 -0
  254. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/__init__.py +0 -0
  255. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/conftest.py +0 -0
  256. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_admin.py +0 -0
  257. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_api_client.py +0 -0
  258. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_assessments.py +0 -0
  259. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_auth.py +0 -0
  260. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_credentials.py +0 -0
  261. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_datasets.py +0 -0
  262. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_deployment_runs.py +0 -0
  263. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_deployments.py +0 -0
  264. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_evals.py +0 -0
  265. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_login.py +0 -0
  266. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_main.py +0 -0
  267. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_optimizations.py +0 -0
  268. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_org.py +0 -0
  269. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_pagination.py +0 -0
  270. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_parity.py +0 -0
  271. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_provider_credentials.py +0 -0
  272. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_schema_sample.py +0 -0
  273. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_shell.py +0 -0
  274. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_signatures.py +0 -0
  275. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_tool_servers.py +0 -0
  276. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_top.py +0 -0
  277. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_traces.py +0 -0
  278. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_traces_search_wire.py +0 -0
  279. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_tree_help.py +0 -0
  280. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cli/test_valsets.py +0 -0
  281. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_client.py +0 -0
  282. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_client_floor.py +0 -0
  283. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_client_program_names.py +0 -0
  284. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_clustering/__init__.py +0 -0
  285. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_clustering/conftest.py +0 -0
  286. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_clustering/test_edge_cases.py +0 -0
  287. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_clustering/test_hash_consistency.py +0 -0
  288. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_clustering/test_type_conversion.py +0 -0
  289. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_cmp551_grouping_identity.py +0 -0
  290. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_configuration.py +0 -0
  291. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_context.py +0 -0
  292. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_context_propagation.py +0 -0
  293. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_dataset_polling.py +0 -0
  294. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_dataset_sync.py +0 -0
  295. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_datasets.py +0 -0
  296. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_decision_client_spans.py +0 -0
  297. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_decorators.py +0 -0
  298. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_deploy.py +0 -0
  299. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_deploy_mcp_tool_references.py +0 -0
  300. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_deploy_program_id.py +0 -0
  301. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_deployment_metadata.py +0 -0
  302. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_deployment_runs.py +0 -0
  303. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_deployments_read.py +0 -0
  304. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_eval.py +0 -0
  305. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_eval_example_attribution.py +0 -0
  306. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_eval_start.py +0 -0
  307. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_eval_trace_grouping.py +0 -0
  308. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_exception_paths.py +0 -0
  309. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_execute.py +0 -0
  310. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_execute_run_identity.py +0 -0
  311. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_exporter.py +0 -0
  312. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_exporter_program_names.py +0 -0
  313. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_exporter_queue.py +0 -0
  314. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_exporter_real.py +0 -0
  315. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_fake_lm.py +0 -0
  316. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_gepa_callback.py +0 -0
  317. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_gepa_integration.py +0 -0
  318. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_gepa_output_normalization.py +0 -0
  319. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_gepa_resume.py +0 -0
  320. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_identity.py +0 -0
  321. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_imaging.py +0 -0
  322. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_import_weight.py +0 -0
  323. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_ingest_conformance.py +0 -0
  324. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_ingest_second_producer.py +0 -0
  325. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_integration.py +0 -0
  326. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_interpreter_callback.py +0 -0
  327. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_ir_encoder_properties.py +0 -0
  328. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_ir_golden_vectors.py +0 -0
  329. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_ir_roundtrip.py +0 -0
  330. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_lm15_native_calls.py +0 -0
  331. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_lm_configuration.py +0 -0
  332. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_lm_usage_shapes.py +0 -0
  333. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_mcp_tools.py +0 -0
  334. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_models.py +0 -0
  335. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_multi_client_stress_readiness.py +0 -0
  336. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimization_progress.py +0 -0
  337. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimization_start.py +0 -0
  338. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimize.py +0 -0
  339. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimize_budget.py +0 -0
  340. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimize_lift.py +0 -0
  341. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimize_lms.py +0 -0
  342. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimize_proposer.py +0 -0
  343. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimize_reflection_lm.py +0 -0
  344. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_optimize_terminal_failure.py +0 -0
  345. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_packaging.py +0 -0
  346. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_packaging_callable_source.py +0 -0
  347. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_packaging_mcp_tools.py +0 -0
  348. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_packaging_program_source.py +0 -0
  349. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_poll_until.py +0 -0
  350. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_program_id_deprecation.py +0 -0
  351. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_program_identity.py +0 -0
  352. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_program_lineage_persist.py +0 -0
  353. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_program_names_in_deploy.py +0 -0
  354. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_programs_create.py +0 -0
  355. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_project_payload.py +0 -0
  356. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_real_provider_twins_readiness.py +0 -0
  357. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_reflector_properties.py +0 -0
  358. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_reflector_smoke.py +0 -0
  359. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_release_stress_script.py +0 -0
  360. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_replaced_names.py +0 -0
  361. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_repro_reactv2_bugs.py +0 -0
  362. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_require_version_bump.py +0 -0
  363. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_retry.py +0 -0
  364. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_reversible_failures.py +0 -0
  365. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_rlm_patch.py +0 -0
  366. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_run_attribution_paths.py +0 -0
  367. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_runtime_tools.py +0 -0
  368. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_schemas.py +0 -0
  369. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_seed_review_run.py +0 -0
  370. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_signature_inputs.py +0 -0
  371. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_span_references.py +0 -0
  372. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_span_sent_once.py +0 -0
  373. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_system_one.py +0 -0
  374. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_trace_context_concurrency.py +0 -0
  375. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_trace_fact_wire_contract.py +0 -0
  376. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_trace_facts.py +0 -0
  377. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_trace_render.py +0 -0
  378. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_traces_read.py +0 -0
  379. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_tracked_gepa.py +0 -0
  380. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_tracker_enrich.py +0 -0
  381. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_truncation.py +0 -0
  382. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_upload_auth_headers.py +0 -0
  383. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_valsets_api.py +0 -0
  384. {cmpnd-0.55.0 → cmpnd-0.56.0}/tests/test_xxh64.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cmpnd
3
- Version: 0.55.0
3
+ Version: 0.56.0
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>
@@ -4,10 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import sys
7
+ import urllib.parse
7
8
  from argparse import ArgumentParser, _SubParsersAction
8
9
  from typing import Any
9
10
 
10
11
  from .. import api_client, pagination
12
+ from . import provider_credentials
11
13
 
12
14
 
13
15
  # Every id in this group is the value `programs list` prints as `program_hash`,
@@ -49,6 +51,11 @@ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
49
51
  ls = sub.add_parser("list", help="List programs")
50
52
  ls.add_argument("--name", dest="program_name", help="Filter by program name")
51
53
  ls.add_argument("--model", dest="model_name", help="Filter by model name")
54
+ ls.add_argument(
55
+ "--trashed",
56
+ action="store_true",
57
+ help="List the programs in the Trash instead, with when each is purged",
58
+ )
52
59
  ls.add_argument("--order-by", default="run_count", help="Sort field (default: run_count)")
53
60
  ls.add_argument("--order", default="desc", choices=["asc", "desc"], help="Sort direction")
54
61
  pagination.add_args(ls, with_page_aliases=True, default_limit=50)
@@ -93,6 +100,37 @@ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
93
100
  reg_show.add_argument("id", help="Registry ID")
94
101
  reg_show.set_defaults(func=handle_registry_show)
95
102
 
103
+ # The Trash. Each command takes a program name or a p_ id. A program in the
104
+ # Trash leaves every view and frees its place under the plan's program limit,
105
+ # and stays restorable until it is purged.
106
+ trash = sub.add_parser(
107
+ "trash",
108
+ help="Move a program to the Trash",
109
+ description=(
110
+ "Move PROGRAM to the Trash. It leaves every view and no longer counts toward your plan's program "
111
+ "limit. Restore it with `cmpnd programs restore PROGRAM` before it is purged, 7 days later."
112
+ ),
113
+ )
114
+ trash.add_argument("program", metavar="PROGRAM", help="Program name or p_ id")
115
+ trash.set_defaults(func=handle_trash)
116
+
117
+ restore = sub.add_parser("restore", help="Restore a program from the Trash")
118
+ restore.add_argument("program", metavar="PROGRAM", help="Program name or p_ id")
119
+ restore.set_defaults(func=handle_restore)
120
+
121
+ purge = sub.add_parser(
122
+ "purge",
123
+ help="Delete a program in the Trash for good",
124
+ description=(
125
+ "Delete PROGRAM, which must be in the Trash, for good, or every program in the Trash with --all. "
126
+ "Nothing restores a purged program."
127
+ ),
128
+ )
129
+ purge.add_argument("program", metavar="PROGRAM", nargs="?", default=None, help="Program name or p_ id")
130
+ purge.add_argument("--all", action="store_true", help="Purge every program in the Trash")
131
+ purge.add_argument("--force", action="store_true", help="Skip the confirmation (required when not on a terminal)")
132
+ purge.set_defaults(func=handle_purge)
133
+
96
134
  select = sub.add_parser("select", help="Pin an optimization run as this program's chosen version")
97
135
  select.add_argument("id", help="Program hash, as `programs list` prints it in the program_hash column")
98
136
  select.add_argument("--run", dest="run_id", required=True, help="Optimization run ID (op_...) to pin")
@@ -140,6 +178,22 @@ def handle_create(client: api_client.ApiClient, args: Any) -> Any:
140
178
 
141
179
 
142
180
  def handle_list(client: api_client.ApiClient, args: Any) -> Any:
181
+ if getattr(args, "trashed", False):
182
+ # The Trash is its own short list, unpaged and newest first, so the search filters, sort and paging do
183
+ # not apply. A flag left at its default cannot be told apart from one not passed.
184
+ for attr, flag, default in [
185
+ ("program_name", "--name", None),
186
+ ("model_name", "--model", None),
187
+ ("order_by", "--order-by", "run_count"),
188
+ ("order", "--order", "desc"),
189
+ ("limit", "--limit", 50),
190
+ ("offset", "--offset", 0),
191
+ ("per_page", "--per-page", None),
192
+ ("page", "--page", None),
193
+ ]:
194
+ if getattr(args, attr, default) != default:
195
+ raise api_client.CliError(f"{flag} does not apply to --trashed, which lists the whole Trash")
196
+ return client.get("/api/v1/programs/trash")
143
197
  params: dict[str, str] = pagination.wire(args, "per_page", as_str=True)
144
198
  params["order_by"] = args.order_by
145
199
  params["order_direction"] = args.order
@@ -189,3 +243,88 @@ def handle_select(client: api_client.ApiClient, args: Any) -> Any:
189
243
  audit trail records who made it.
190
244
  """
191
245
  return client.post(f"/api/v1/programs/{args.id}/select", json={"optimization_run_id": args.run_id})
246
+
247
+
248
+ def _resolve(client: api_client.ApiClient, ref: str) -> tuple[str, bool | None]:
249
+ """Return the program id ``ref`` names, and whether it is in the Trash when known.
250
+
251
+ A ``p_`` id is used as given, because no program name starts with ``p_``, and its
252
+ Trash state is unknown until the route answers. A name is looked up, which never
253
+ makes a program, and the answer says whether it is in the Trash.
254
+ """
255
+ from ... import programs
256
+
257
+ if ref.lower().startswith("p_"):
258
+ return ref, None
259
+ try:
260
+ programs.validate_program_name(ref)
261
+ except ValueError as exc:
262
+ raise api_client.CliError(str(exc)) from exc
263
+ try:
264
+ answer = client.get("/api/v1/programs/by-name/" + urllib.parse.quote(ref, safe=""))
265
+ except api_client.CliError as exc:
266
+ if exc.status_code == 404:
267
+ raise api_client.CliError(f"no program in your organization is named {ref!r}") from exc
268
+ if exc.status_code == 410:
269
+ raise api_client.CliError(f"{ref!r} belongs to a purged program, which nothing restores") from exc
270
+ raise
271
+ return answer["program_id"], bool(answer.get("trashed"))
272
+
273
+
274
+ def handle_trash(client: api_client.ApiClient, args: Any) -> Any:
275
+ """Move a program to the Trash, which frees its place under the plan's program limit."""
276
+ from . import top
277
+
278
+ program_id, trashed = _resolve(client, args.program)
279
+ if trashed:
280
+ print(
281
+ f"{args.program} is already in the Trash. Restore it with `{top.hint_programs('restore', args.program)}`.",
282
+ file=sys.stderr,
283
+ )
284
+ return {"program_id": program_id, "trashed": True}
285
+ return client.post(f"/api/v1/programs/{program_id}/trash")
286
+
287
+
288
+ def handle_restore(client: api_client.ApiClient, args: Any) -> Any:
289
+ """Return a program from the Trash. The server refuses it when the plan's program limit is full."""
290
+ from . import top
291
+
292
+ program_id, trashed = _resolve(client, args.program)
293
+ if trashed is False:
294
+ # A name another program took from a Trashed one reaches the new, live program. The Trashed one is
295
+ # restored by its id.
296
+ raise api_client.CliError(
297
+ f"{args.program} is not in the Trash. If its name now belongs to another program, restore the one "
298
+ f"in the Trash by the p_ id that `{top.hint_programs('list', '--trashed')}` prints."
299
+ )
300
+ return client.post(f"/api/v1/programs/{program_id}/restore")
301
+
302
+
303
+ def handle_purge(client: api_client.ApiClient, args: Any) -> Any:
304
+ """Delete one program in the Trash, or the whole Trash with ``--all``, for good."""
305
+ from . import top
306
+
307
+ consequence = "Nothing restores a purged program, and its traces stay hidden."
308
+ if args.all:
309
+ if args.program is not None:
310
+ raise api_client.CliError("name either one PROGRAM or --all, not both")
311
+ # The count is for the person confirming. --force skips the listing, which needs traces:read, so a key
312
+ # that may only write can still empty the Trash.
313
+ if not args.force:
314
+ listed = client.get("/api/v1/programs/trash")
315
+ count = len(listed.get("programs") or [])
316
+ if count == 0:
317
+ return {"purged": 0, "kept": 0}
318
+ noun = "program" if count == 1 else "programs"
319
+ provider_credentials.confirm(args, f"the {count} {noun} in the Trash for good", consequence)
320
+ return client.post("/api/v1/programs/trash/empty")
321
+ if args.program is None:
322
+ raise api_client.CliError("name the PROGRAM to purge, or pass --all to empty the Trash")
323
+ program_id, trashed = _resolve(client, args.program)
324
+ if trashed is False:
325
+ raise api_client.CliError(
326
+ f"{args.program} is live, and only a program in the Trash can be purged. "
327
+ f"Move it there first with `{top.hint_programs('trash', args.program)}`."
328
+ )
329
+ provider_credentials.confirm(args, f"the program {args.program} for good", consequence)
330
+ return client.post(f"/api/v1/programs/{program_id}/purge")
@@ -213,6 +213,11 @@ def hint_programs_create(name: str = "capital-qa") -> str:
213
213
  return f"cmpnd programs create {name}"
214
214
 
215
215
 
216
+ def hint_programs(verb: str, *args: str) -> str:
217
+ """How to run a ``cmpnd programs`` verb with ARGS. The shell has no programs verb, so this is the CLI form."""
218
+ return " ".join(("cmpnd programs", verb, *args))
219
+
220
+
216
221
  def _hint_status(deployment_id: str, follow: bool = False) -> str:
217
222
  short = _short_id(deployment_id)
218
223
  if _SHELL_MODE:
@@ -160,6 +160,9 @@ DOMAIN_VERBS: dict[str, str] = {
160
160
  "deny": "the negative half of allow; the tool stays listed and stops being callable",
161
161
  "switch": "moves a local pointer among stored keys; nothing on the server changes",
162
162
  "leave": "removes your own membership, which is not deleting the org or anyone else's place in it",
163
+ "trash": "moves a program to the Trash, where it stays restorable, and delete would read as the permanent purge",
164
+ "restore": "returns a program from the Trash with its history, which create would not",
165
+ "purge": "deletes a program in the Trash for good, the irreversible step after trash",
163
166
  }
164
167
 
165
168
  #: Leaves naming a related collection or sub-record rather than an action.
@@ -29,7 +29,7 @@ def send_resolve(client: _http.ApiClient, name: str) -> dict[str, Any] | None:
29
29
 
30
30
  The answer is ``{"program_id", "name", "created"}``. ``created`` is true when this
31
31
  request made the program. Returns None when the server has no program names, and
32
- raises :class:`cmpnd.ApiError` for any other refusal, such as a purged name.
32
+ raises :class:`cmpnd.ApiError` for any other refusal, such as the plan's program limit.
33
33
 
34
34
  The request is idempotent: serve gives one name one program, so a retried request
35
35
  that lost its answer finds the program the first request made.
@@ -41,6 +41,47 @@ The CLI refuses `--program-id`, `cmpnd programs create` without a program
41
41
  name, and its `--name`. These inputs are deprecated, and the error shows the
42
42
  program name form.
43
43
 
44
+ ## The Trash
45
+
46
+ A program in the Trash leaves every view, and its traces with it, until it is
47
+ restored or purged. The web app's program page moves a program there with Move
48
+ to Trash, and these commands do the same from a terminal. Each takes a program
49
+ name or a `p_` id. The CLI looks a name up with
50
+ `GET /api/v1/programs/by-name/{name}`, which finds a program in the Trash too,
51
+ and sends the id.
52
+
53
+ - `cmpnd programs trash PROGRAM` moves a live program to the Trash. A program
54
+ already there is left alone.
55
+ - `cmpnd programs list --trashed` lists the whole Trash, newest first, with
56
+ when each program is purged. It takes no filter, sort or paging flag.
57
+ - `cmpnd programs restore PROGRAM` returns a program from the Trash.
58
+ - `cmpnd programs purge PROGRAM` deletes a program in the Trash for good, and
59
+ `cmpnd programs purge --all` empties the Trash. Nothing restores a purged
60
+ program. Both ask for confirmation at a terminal and need `--force` anywhere
61
+ else. A live program is never purged.
62
+ A program created by name cannot be purged yet. It stays in the Trash, where
63
+ it no longer counts toward a plan's program cap, and `purge --all` answers
64
+ how many such programs it kept.
65
+
66
+ A program in the Trash or purged gives up its name. The next
67
+ `cmpnd programs create`, `cmpnd deploy` or SDK run under that name gets a new
68
+ program. Restore the old one by its `p_` id, which `cmpnd programs list
69
+ --trashed` prints, and it comes back without the name.
70
+
71
+ The purge job purges a program 7 days after it entered the Trash. Listing the
72
+ Trash needs `traces:read`, and the other commands need `traces:write` or
73
+ `optimizations:write`.
74
+
75
+ A plan may cap an organization's live programs, the ones outside the Trash
76
+ that no optimization produced. At the cap, a new program name in
77
+ `cmpnd programs create`, `cmpnd deploy` or `cmpnd optimize` and
78
+ `cmpnd programs restore` are refused with a message that says how many the
79
+ plan allows and how to free one. A live program's name always resolves. A name
80
+ that only a program in the Trash answers to goes to a new program, which needs
81
+ a free place like any other. Moving a program to the Trash frees its place at
82
+ once. `cmpnd programs list --trashed` reports `live_programs` and
83
+ `program_limit`, which is null when no cap applies.
84
+
44
85
  ## When the server no longer supports your release
45
86
 
46
87
  Every answer from the platform names the oldest `cmpnd` release it supports.
@@ -276,8 +317,9 @@ The `signatures` and `programs` groups name programs differently.
276
317
  hash, which identifies a program's inputs and outputs and everything recorded
277
318
  under them: traces, examples, evals and revisions. The CLI passes an id
278
319
  through unchanged, and serve tells the two apart by the `p_` prefix.
279
- `cmpnd programs` takes a compiled program hash, the value in the
280
- `program_hash` column of `cmpnd programs list`. Its routes resolve the hash
320
+ `cmpnd programs` reads take a compiled program hash, the value in the
321
+ `program_hash` column of `cmpnd programs list`, and its Trash commands take a
322
+ program name or id ("The Trash" above). Its routes resolve the hash
281
323
  through optimization runs, so they do not find a signature hash. Each group's
282
324
  `--help` names the column its IDs come from.
283
325
 
@@ -843,6 +843,9 @@ trace bodies, at the top level and in `started_fact`, before it compares or send
843
843
  - **A refusal settles the name with no id.** Serve answers 404 or 405 when it has no
844
844
  program names, as self-host release 2026-001 does, and 409, 422 or 403 for a name it
845
845
  will not resolve. The traces go without an id, and the exporter warns once per name.
846
+ An organization with as many live programs as its plan allows gets a 409 for a new
847
+ name, whose text says how to free one. The name stays settled for the process, so a
848
+ process started after a program was moved to the Trash resolves it.
846
849
  - **An outage leaves the name unsettled.** A 5xx, a 429 or an unreachable server is
847
850
  retried with backoff, wider than the data sends' rule because the request is a
848
851
  get-or-create that a repeat cannot double. The names in one batch share one retry
@@ -5,7 +5,7 @@ name = "cmpnd"
5
5
  # it's new) — no git tags. Bump with `uv version --bump {minor,patch}` or by
6
6
  # hand. Runtime `cmpnd.__version__` reads installed metadata, so it tracks
7
7
  # this automatically. See docs/plans/2026-07-02-sdk-pypi-tag-free-publish.md.
8
- version = "0.55.0"
8
+ version = "0.56.0"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.11"
@@ -0,0 +1,290 @@
1
+ """Tests for cmpnd.cli.commands.programs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ import pytest
8
+
9
+ from cmpnd.cli.api_client import CliError
10
+ from cmpnd.cli.commands import programs
11
+ from cmpnd.cli.commands.programs import (
12
+ handle_list,
13
+ handle_registry_list,
14
+ handle_registry_show,
15
+ handle_select,
16
+ handle_show,
17
+ handle_stats,
18
+ handle_traces,
19
+ handle_usage,
20
+ )
21
+
22
+
23
+ def _build_parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(prog="cmpnd")
25
+ sub = parser.add_subparsers(dest="resource")
26
+ programs.register(sub)
27
+ return parser
28
+
29
+
30
+ class TestProgramsList:
31
+ def test_default_params(self, mock_client):
32
+ mock_client.get.return_value = {"programs": []}
33
+ args = argparse.Namespace(
34
+ program_name=None, model_name=None, order_by="run_count", order="desc", limit=50, offset=0
35
+ )
36
+ result = handle_list(mock_client, args)
37
+ mock_client.get.assert_called_once_with(
38
+ "/api/v1/programs/v2",
39
+ params={"page": "1", "per_page": "50", "order_by": "run_count", "order_direction": "desc"},
40
+ )
41
+ assert result == {"programs": []}
42
+
43
+ def test_with_filters(self, mock_client):
44
+ mock_client.get.return_value = {"programs": []}
45
+ args = argparse.Namespace(
46
+ program_name="RAG", model_name="gpt-4o", order_by="run_count", order="asc", limit=10, offset=10
47
+ )
48
+ handle_list(mock_client, args)
49
+ params = mock_client.get.call_args[1]["params"]
50
+ assert params["program_name"] == "RAG"
51
+ assert params["model_name"] == "gpt-4o"
52
+ assert params["page"] == "2"
53
+
54
+ def test_deprecated_paging_still_works_and_warns(self, mock_client, capsys):
55
+ parser = _build_parser()
56
+ args = parser.parse_args(["programs", "list", "--page", "2", "--per-page", "5"])
57
+ mock_client.get.return_value = {"programs": []}
58
+ handle_list(mock_client, args)
59
+ params = mock_client.get.call_args[1]["params"]
60
+ assert params["page"] == "2"
61
+ assert params["per_page"] == "5"
62
+ assert "deprecated" in capsys.readouterr().err
63
+
64
+ def test_non_multiple_offset_errors(self, mock_client):
65
+ parser = _build_parser()
66
+ args = parser.parse_args(["programs", "list", "--limit", "10", "--offset", "5"])
67
+ with pytest.raises(CliError):
68
+ handle_list(mock_client, args)
69
+
70
+
71
+ class TestProgramsShow:
72
+ def test_calls_correct_endpoint(self, mock_client):
73
+ mock_client.get.return_value = {"program_hash": 123}
74
+ args = argparse.Namespace(id="123")
75
+ result = handle_show(mock_client, args)
76
+ mock_client.get.assert_called_once_with("/api/v1/programs/123")
77
+ assert result["program_hash"] == 123
78
+
79
+
80
+ class TestProgramsTraces:
81
+ def test_calls_correct_endpoint(self, mock_client):
82
+ mock_client.get.return_value = {"traces": []}
83
+ args = argparse.Namespace(id="456", limit=10, offset=5)
84
+ result = handle_traces(mock_client, args)
85
+ mock_client.get.assert_called_once_with("/api/v1/programs/456/traces", params={"limit": "10", "offset": "5"})
86
+ assert result == {"traces": []}
87
+
88
+
89
+ class TestProgramsStats:
90
+ def test_calls_correct_endpoint(self, mock_client):
91
+ mock_client.get.return_value = {"trace_count": 12}
92
+ result = handle_stats(mock_client, argparse.Namespace(id="789"))
93
+ mock_client.get.assert_called_once_with("/api/v1/programs/789/execution-stats")
94
+ assert result == {"trace_count": 12}
95
+
96
+
97
+ class TestProgramsUsage:
98
+ def test_sends_the_default_window(self, mock_client):
99
+ mock_client.get.return_value = {"history": []}
100
+ handle_usage(mock_client, argparse.Namespace(id="789", hours=24, period=None))
101
+ mock_client.get.assert_called_once_with("/api/v1/programs/789/usage-history", params={"hours": "24"})
102
+
103
+ def test_period_is_sent_only_when_given(self, mock_client):
104
+ mock_client.get.return_value = {"history": []}
105
+ handle_usage(mock_client, argparse.Namespace(id="789", hours=6, period="minute"))
106
+ assert mock_client.get.call_args[1]["params"] == {"hours": "6", "period": "minute"}
107
+
108
+ def test_parser_rejects_an_unknown_period(self):
109
+ # An unrecognised period buckets hourly server-side rather than failing,
110
+ # so a typo would answer a different question than the one asked.
111
+ parser = _build_parser()
112
+ with pytest.raises(SystemExit):
113
+ parser.parse_args(["programs", "usage", "789", "--period", "fortnight"])
114
+
115
+
116
+ class TestProgramsRegistry:
117
+ def test_list_uses_limit_offset(self, mock_client):
118
+ mock_client.get.return_value = {"programs": [], "total": 0}
119
+ handle_registry_list(mock_client, argparse.Namespace(limit=25, offset=50))
120
+ mock_client.get.assert_called_once_with("/api/v1/programs/registry", params={"limit": "25", "offset": "50"})
121
+
122
+ def test_show_calls_correct_endpoint(self, mock_client):
123
+ mock_client.get.return_value = {"registry": {}}
124
+ handle_registry_show(mock_client, argparse.Namespace(id="reg-1"))
125
+ mock_client.get.assert_called_once_with("/api/v1/programs/registry/reg-1")
126
+
127
+ def test_registry_is_a_nested_group(self):
128
+ parser = _build_parser()
129
+ assert parser.parse_args(["programs", "registry", "list"]).func is programs.handle_registry_list
130
+ assert parser.parse_args(["programs", "registry", "show", "reg-1"]).func is programs.handle_registry_show
131
+
132
+
133
+ class TestProgramsSelect:
134
+ def test_posts_the_key_the_server_reads(self, mock_client):
135
+ mock_client.post.return_value = {"status": "selected"}
136
+ result = handle_select(mock_client, argparse.Namespace(id="789", run_id="op_abc"))
137
+ mock_client.post.assert_called_once_with("/api/v1/programs/789/select", json={"optimization_run_id": "op_abc"})
138
+ assert result == {"status": "selected"}
139
+
140
+ def test_run_id_is_required(self):
141
+ parser = _build_parser()
142
+ with pytest.raises(SystemExit):
143
+ parser.parse_args(["programs", "select", "789"])
144
+
145
+
146
+ def _named(program_id: str, name: str, *, trashed: bool) -> dict[str, object]:
147
+ return {"program_id": program_id, "name": name, "created": False, "trashed": trashed}
148
+
149
+
150
+ class TestProgramsTrash:
151
+ def test_a_name_resolves_then_moves_to_the_trash(self, mock_client):
152
+ mock_client.get.return_value = _named("p_0123456789Ab", "triage", trashed=False)
153
+ mock_client.post.return_value = {"program_id": "p_0123456789Ab", "trashed": True}
154
+ args = _build_parser().parse_args(["programs", "trash", "triage"])
155
+ result = args.func(mock_client, args)
156
+ mock_client.get.assert_called_once_with("/api/v1/programs/by-name/triage")
157
+ mock_client.post.assert_called_once_with("/api/v1/programs/p_0123456789Ab/trash")
158
+ assert result == {"program_id": "p_0123456789Ab", "trashed": True}
159
+
160
+ def test_a_program_id_goes_straight_to_the_route(self, mock_client):
161
+ mock_client.post.return_value = {"program_id": "p_0123456789Ab", "trashed": True}
162
+ args = _build_parser().parse_args(["programs", "trash", "p_0123456789Ab"])
163
+ args.func(mock_client, args)
164
+ mock_client.get.assert_not_called()
165
+ mock_client.post.assert_called_once_with("/api/v1/programs/p_0123456789Ab/trash")
166
+
167
+ def test_a_name_already_in_the_trash_is_not_moved_again(self, mock_client, capsys):
168
+ mock_client.get.return_value = _named("p_0123456789Ab", "triage", trashed=True)
169
+ args = _build_parser().parse_args(["programs", "trash", "triage"])
170
+ result = args.func(mock_client, args)
171
+ mock_client.post.assert_not_called()
172
+ assert result == {"program_id": "p_0123456789Ab", "trashed": True}
173
+ assert "already in the Trash" in capsys.readouterr().err
174
+
175
+ def test_an_unknown_name_says_so(self, mock_client):
176
+ mock_client.get.side_effect = CliError("no program has this name", status_code=404)
177
+ args = _build_parser().parse_args(["programs", "trash", "nobody"])
178
+ with pytest.raises(CliError, match="no program in your organization is named 'nobody'"):
179
+ args.func(mock_client, args)
180
+ mock_client.post.assert_not_called()
181
+
182
+ def test_a_name_resolves_through_the_by_name_route(self, mock_client):
183
+ mock_client.get.return_value = _named("p_0123456789Ab", "a.b_c", trashed=False)
184
+ args = _build_parser().parse_args(["programs", "trash", "a.b_c"])
185
+ args.func(mock_client, args)
186
+ mock_client.get.assert_called_once_with("/api/v1/programs/by-name/a.b_c")
187
+
188
+ def test_a_bad_name_fails_before_any_request(self, mock_client):
189
+ args = _build_parser().parse_args(["programs", "trash", "has space"])
190
+ with pytest.raises(CliError, match="not a valid program name"):
191
+ args.func(mock_client, args)
192
+ mock_client.get.assert_not_called()
193
+
194
+
195
+ class TestProgramsRestore:
196
+ def test_restores_a_trashed_name(self, mock_client):
197
+ mock_client.get.return_value = _named("p_0123456789Ab", "triage", trashed=True)
198
+ mock_client.post.return_value = {"program_id": "p_0123456789Ab", "trashed": False}
199
+ args = _build_parser().parse_args(["programs", "restore", "triage"])
200
+ args.func(mock_client, args)
201
+ mock_client.post.assert_called_once_with("/api/v1/programs/p_0123456789Ab/restore")
202
+
203
+ def test_a_live_name_is_not_in_the_trash(self, mock_client):
204
+ mock_client.get.return_value = _named("p_0123456789Ab", "triage", trashed=False)
205
+ args = _build_parser().parse_args(["programs", "restore", "triage"])
206
+ with pytest.raises(CliError, match="not in the Trash") as refused:
207
+ args.func(mock_client, args)
208
+ assert "`cmpnd programs list --trashed`" in str(refused.value)
209
+ mock_client.post.assert_not_called()
210
+
211
+
212
+ class TestProgramsPurge:
213
+ def test_purge_needs_force_off_a_terminal(self, mock_client, monkeypatch):
214
+ monkeypatch.setattr("sys.stdin.isatty", lambda: False)
215
+ mock_client.get.return_value = _named("p_0123456789Ab", "triage", trashed=True)
216
+ args = _build_parser().parse_args(["programs", "purge", "triage"])
217
+ with pytest.raises(CliError, match="--force"):
218
+ args.func(mock_client, args)
219
+ mock_client.post.assert_not_called()
220
+
221
+ def test_purge_with_force_purges(self, mock_client):
222
+ mock_client.get.return_value = _named("p_0123456789Ab", "triage", trashed=True)
223
+ mock_client.post.return_value = {"program_id": "p_0123456789Ab", "purged": True}
224
+ args = _build_parser().parse_args(["programs", "purge", "triage", "--force"])
225
+ assert args.func(mock_client, args) == {"program_id": "p_0123456789Ab", "purged": True}
226
+ mock_client.post.assert_called_once_with("/api/v1/programs/p_0123456789Ab/purge")
227
+
228
+ def test_a_live_program_is_never_purged(self, mock_client):
229
+ mock_client.get.return_value = _named("p_0123456789Ab", "triage", trashed=False)
230
+ args = _build_parser().parse_args(["programs", "purge", "triage", "--force"])
231
+ with pytest.raises(CliError, match="cmpnd programs trash triage"):
232
+ args.func(mock_client, args)
233
+ mock_client.post.assert_not_called()
234
+
235
+ def test_all_with_force_empties_the_trash_without_listing_it(self, mock_client):
236
+ # Listing needs traces:read, so --force skips it and a key that may only write still empties the Trash.
237
+ mock_client.post.return_value = {"purged": 1, "kept": 0}
238
+ args = _build_parser().parse_args(["programs", "purge", "--all", "--force"])
239
+ assert args.func(mock_client, args) == {"purged": 1, "kept": 0}
240
+ mock_client.get.assert_not_called()
241
+ mock_client.post.assert_called_once_with("/api/v1/programs/trash/empty")
242
+
243
+ def test_all_confirms_with_the_count_at_a_terminal(self, mock_client, monkeypatch):
244
+ mock_client.get.return_value = {"programs": [{"program_id": "p_0123456789Ab"}] * 2, "retention_days": 7}
245
+ mock_client.post.return_value = {"purged": 2, "kept": 0}
246
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
247
+ prompts: list[str] = []
248
+ monkeypatch.setattr("builtins.input", lambda prompt: prompts.append(prompt) or "yes")
249
+ args = _build_parser().parse_args(["programs", "purge", "--all"])
250
+ assert args.func(mock_client, args) == {"purged": 2, "kept": 0}
251
+ mock_client.get.assert_called_once_with("/api/v1/programs/trash")
252
+ mock_client.post.assert_called_once_with("/api/v1/programs/trash/empty")
253
+ assert prompts
254
+
255
+ def test_all_with_an_empty_trash_sends_nothing(self, mock_client):
256
+ mock_client.get.return_value = {"programs": [], "retention_days": 7}
257
+ args = _build_parser().parse_args(["programs", "purge", "--all"])
258
+ assert args.func(mock_client, args) == {"purged": 0, "kept": 0}
259
+ mock_client.post.assert_not_called()
260
+
261
+ def test_all_and_a_program_together_are_refused(self, mock_client):
262
+ args = _build_parser().parse_args(["programs", "purge", "triage", "--all", "--force"])
263
+ with pytest.raises(CliError, match="either"):
264
+ args.func(mock_client, args)
265
+ mock_client.get.assert_not_called()
266
+ mock_client.post.assert_not_called()
267
+
268
+
269
+ class TestProgramsListTrashed:
270
+ def test_lists_the_trash(self, mock_client):
271
+ mock_client.get.return_value = {"programs": [], "retention_days": 7}
272
+ args = _build_parser().parse_args(["programs", "list", "--trashed"])
273
+ assert args.func(mock_client, args) == {"programs": [], "retention_days": 7}
274
+ mock_client.get.assert_called_once_with("/api/v1/programs/trash")
275
+
276
+ def test_search_filters_do_not_apply(self, mock_client):
277
+ args = _build_parser().parse_args(["programs", "list", "--trashed", "--name", "x"])
278
+ with pytest.raises(CliError, match="--name does not apply"):
279
+ args.func(mock_client, args)
280
+ mock_client.get.assert_not_called()
281
+
282
+ @pytest.mark.parametrize(
283
+ "flag",
284
+ [["--order-by", "name"], ["--order", "asc"], ["--limit", "5"], ["--offset", "10"]],
285
+ )
286
+ def test_paging_and_sort_do_not_apply(self, mock_client, flag):
287
+ args = _build_parser().parse_args(["programs", "list", "--trashed", *flag])
288
+ with pytest.raises(CliError, match=f"{flag[0]} does not apply"):
289
+ args.func(mock_client, args)
290
+ mock_client.get.assert_not_called()
@@ -496,7 +496,7 @@ wheels = [
496
496
 
497
497
  [[package]]
498
498
  name = "cmpnd"
499
- version = "0.55.0"
499
+ version = "0.56.0"
500
500
  source = { editable = "." }
501
501
  dependencies = [
502
502
  { name = "dspy" },