cmpnd 0.7.2__tar.gz → 0.8.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 (228) hide show
  1. {cmpnd-0.7.2 → cmpnd-0.8.0}/PKG-INFO +4 -1
  2. cmpnd-0.8.0/cmpnd/imaging.py +162 -0
  3. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/models.py +15 -1
  4. {cmpnd-0.7.2 → cmpnd-0.8.0}/docs/sdk-instrumentation.md +1 -0
  5. {cmpnd-0.7.2 → cmpnd-0.8.0}/pyproject.toml +11 -1
  6. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimize_loop.py +12 -4
  7. cmpnd-0.8.0/tests/test_imaging.py +193 -0
  8. {cmpnd-0.7.2 → cmpnd-0.8.0}/uv.lock +102 -3
  9. {cmpnd-0.7.2 → cmpnd-0.8.0}/.gitignore +0 -0
  10. {cmpnd-0.7.2 → cmpnd-0.8.0}/CLAUDE.md +0 -0
  11. {cmpnd-0.7.2 → cmpnd-0.8.0}/README.md +0 -0
  12. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/__init__.py +0 -0
  13. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/_gepa_patch.py +0 -0
  14. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/_program_patch.py +0 -0
  15. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/_rlm_patch.py +0 -0
  16. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/callback.py +0 -0
  17. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/__init__.py +0 -0
  18. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/api_client.py +0 -0
  19. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/__init__.py +0 -0
  20. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/admin.py +0 -0
  21. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/auth.py +0 -0
  22. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/datasets.py +0 -0
  23. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/deployments.py +0 -0
  24. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/evals.py +0 -0
  25. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/login.py +0 -0
  26. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/optimizations.py +0 -0
  27. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/org.py +0 -0
  28. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/programs.py +0 -0
  29. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/top.py +0 -0
  30. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/traces.py +0 -0
  31. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/credentials.py +0 -0
  32. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/shell.py +0 -0
  33. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/trace_render.py +0 -0
  34. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/verbs.py +0 -0
  35. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/configuration.py +0 -0
  36. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/context.py +0 -0
  37. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/dataset_sync.py +0 -0
  38. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/datasets.py +0 -0
  39. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/decorators.py +0 -0
  40. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/deployment.py +0 -0
  41. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/eval_handler.py +0 -0
  42. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/execution.py +0 -0
  43. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/exporter.py +0 -0
  44. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/exporter_http.py +0 -0
  45. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/helpers.py +0 -0
  46. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/identity.py +0 -0
  47. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/__init__.py +0 -0
  48. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/decode.py +0 -0
  49. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/encode.py +0 -0
  50. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/hash.py +0 -0
  51. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/program.py +0 -0
  52. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/reflection.py +0 -0
  53. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/types.py +0 -0
  54. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/xxh64.py +0 -0
  55. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimization.py +0 -0
  56. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/__init__.py +0 -0
  57. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/gepa.py +0 -0
  58. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/gepa_callback.py +0 -0
  59. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/resume.py +0 -0
  60. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/tracker.py +0 -0
  61. {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/packaging.py +0 -0
  62. {cmpnd-0.7.2 → cmpnd-0.8.0}/docs/2026-04-02-code-review.md +0 -0
  63. {cmpnd-0.7.2 → cmpnd-0.8.0}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  64. {cmpnd-0.7.2 → cmpnd-0.8.0}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  65. {cmpnd-0.7.2 → cmpnd-0.8.0}/examples/local_ollama.py +0 -0
  66. {cmpnd-0.7.2 → cmpnd-0.8.0}/scripts/capture_review_run.py +0 -0
  67. {cmpnd-0.7.2 → cmpnd-0.8.0}/scripts/fixtures/review_run.json.gz +0 -0
  68. {cmpnd-0.7.2 → cmpnd-0.8.0}/scripts/gepa_resume_bug.py +0 -0
  69. {cmpnd-0.7.2 → cmpnd-0.8.0}/scripts/seed_review_run.py +0 -0
  70. {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/__init__.pyi +0 -0
  71. {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/primitives/__init__.pyi +0 -0
  72. {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/primitives/prediction.pyi +0 -0
  73. {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/utils/__init__.pyi +0 -0
  74. {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/utils/callback.pyi +0 -0
  75. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/__init__.py +0 -0
  76. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/_exporter_helpers.py +0 -0
  77. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/_identity_helpers.py +0 -0
  78. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/__init__.py +0 -0
  79. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/committee_task.py +0 -0
  80. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/conftest.py +0 -0
  81. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  82. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  83. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/fake_lm.py +0 -0
  84. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/helpers.py +0 -0
  85. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_capture_prompts.py +0 -0
  86. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_composite_module.py +0 -0
  87. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_dataset_auto_creation.py +0 -0
  88. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_deploy_execute.py +0 -0
  89. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_deploy_programs.py +0 -0
  90. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_edge_cases.py +0 -0
  91. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_example_hash.py +0 -0
  92. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_gepa_optimization.py +0 -0
  93. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_lm_accuracy.py +0 -0
  94. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_module_tracing.py +0 -0
  95. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimization_lineage.py +0 -0
  96. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimization_metric_identity.py +0 -0
  97. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimize_failures.py +0 -0
  98. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimize_review_committee.py +0 -0
  99. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  100. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_parallel_export.py +0 -0
  101. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_project_attribution.py +0 -0
  102. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_react_tool_spans.py +0 -0
  103. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_save_load.py +0 -0
  104. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_trace_structure.py +0 -0
  105. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_user_attribution.py +0 -0
  106. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/01_predict_qa.json +0 -0
  107. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  108. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  109. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  110. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  111. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  112. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/07_program_of_thought.json +0 -0
  113. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/08_opaque_module.json +0 -0
  114. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  115. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/10_react.json +0 -0
  116. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/11_refine.json +0 -0
  117. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/12_retrieve.json +0 -0
  118. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/13_knn.json +0 -0
  119. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/14_parallel.json +0 -0
  120. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/README.md +0 -0
  121. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/__init__.py +0 -0
  122. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/conftest.py +0 -0
  123. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  124. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/test_logs_renders_traces.py +0 -0
  125. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/test_optimizing_progress.py +0 -0
  126. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/test_unhappy_path.py +0 -0
  127. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/__init__.py +0 -0
  128. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/conftest.py +0 -0
  129. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/seed_parity.py +0 -0
  130. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_auth.py +0 -0
  131. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_datasets.py +0 -0
  132. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_deployments.py +0 -0
  133. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_evals.py +0 -0
  134. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_health.py +0 -0
  135. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_modules.py +0 -0
  136. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_optimizations.py +0 -0
  137. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_programs.py +0 -0
  138. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_signatures.py +0 -0
  139. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_trace_streaming.py +0 -0
  140. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_traces.py +0 -0
  141. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/__init__.py +0 -0
  142. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/_provider.py +0 -0
  143. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/_reliability.py +0 -0
  144. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/mint_bedrock_token.py +0 -0
  145. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_deploy_programs.py +0 -0
  146. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_multi_client_stress.py +0 -0
  147. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_optimize_client_committee.py +0 -0
  148. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_optimize_loop.py +0 -0
  149. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_optimize_loop_wasm.py +0 -0
  150. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_optimize_stress.py +0 -0
  151. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_provider_roles.py +0 -0
  152. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_real_provider_twins.py +0 -0
  153. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_reliability.py +0 -0
  154. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_reliability_classify.py +0 -0
  155. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_rlm_wasm.py +0 -0
  156. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_callback.py +0 -0
  157. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/__init__.py +0 -0
  158. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/conftest.py +0 -0
  159. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_admin.py +0 -0
  160. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_api_client.py +0 -0
  161. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_auth.py +0 -0
  162. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_credentials.py +0 -0
  163. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_datasets.py +0 -0
  164. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_deployments.py +0 -0
  165. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_evals.py +0 -0
  166. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_login.py +0 -0
  167. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_main.py +0 -0
  168. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_optimizations.py +0 -0
  169. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_org.py +0 -0
  170. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_parity.py +0 -0
  171. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_programs.py +0 -0
  172. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_shell.py +0 -0
  173. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_top.py +0 -0
  174. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_traces.py +0 -0
  175. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/__init__.py +0 -0
  176. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/conftest.py +0 -0
  177. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/test_edge_cases.py +0 -0
  178. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/test_hash_consistency.py +0 -0
  179. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/test_type_conversion.py +0 -0
  180. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cmp551_grouping_identity.py +0 -0
  181. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_configuration.py +0 -0
  182. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_context.py +0 -0
  183. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_context_propagation.py +0 -0
  184. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_dataset_polling.py +0 -0
  185. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_dataset_sync.py +0 -0
  186. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_datasets.py +0 -0
  187. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_decorators.py +0 -0
  188. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_deploy.py +0 -0
  189. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_eval.py +0 -0
  190. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_eval_start.py +0 -0
  191. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_exception_paths.py +0 -0
  192. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_execute.py +0 -0
  193. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_exporter.py +0 -0
  194. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_exporter_real.py +0 -0
  195. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_fake_lm.py +0 -0
  196. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_gepa_callback.py +0 -0
  197. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_gepa_integration.py +0 -0
  198. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_gepa_resume.py +0 -0
  199. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_identity.py +0 -0
  200. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_import_weight.py +0 -0
  201. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_integration.py +0 -0
  202. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_ir_encoder_properties.py +0 -0
  203. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_ir_golden_vectors.py +0 -0
  204. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_ir_roundtrip.py +0 -0
  205. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_models.py +0 -0
  206. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimization_progress.py +0 -0
  207. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimization_start.py +0 -0
  208. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimize.py +0 -0
  209. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimize_lift.py +0 -0
  210. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimize_terminal_failure.py +0 -0
  211. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_packaging.py +0 -0
  212. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_packaging_callable_source.py +0 -0
  213. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_packaging_program_source.py +0 -0
  214. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_program_lineage_persist.py +0 -0
  215. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_project_payload.py +0 -0
  216. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_reflector_properties.py +0 -0
  217. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_reflector_smoke.py +0 -0
  218. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_repro_reactv2_bugs.py +0 -0
  219. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_rlm_patch.py +0 -0
  220. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_schemas.py +0 -0
  221. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_seed_review_run.py +0 -0
  222. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_stats_export.py +0 -0
  223. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_trace_render.py +0 -0
  224. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_tracked_gepa.py +0 -0
  225. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_tracker_enrich.py +0 -0
  226. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_truncation.py +0 -0
  227. {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_xxh64.py +0 -0
  228. {cmpnd-0.7.2 → cmpnd-0.8.0}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmpnd
3
- Version: 0.7.2
3
+ Version: 0.8.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>
@@ -28,11 +28,14 @@ Requires-Dist: boto3>=1.34; extra == 'dev'
28
28
  Requires-Dist: hypothesis>=6.100; extra == 'dev'
29
29
  Requires-Dist: jsonschema>=4.0; extra == 'dev'
30
30
  Requires-Dist: numpy>=1.26; extra == 'dev'
31
+ Requires-Dist: pillow>=10.0; extra == 'dev'
31
32
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
32
33
  Requires-Dist: pytest-rerunfailures>=14.0; extra == 'dev'
33
34
  Requires-Dist: pytest-xdist>=3.0; extra == 'dev'
34
35
  Requires-Dist: pytest>=8.0; extra == 'dev'
35
36
  Requires-Dist: ruff>=0.4.0; extra == 'dev'
37
+ Provides-Extra: images
38
+ Requires-Dist: pillow>=10.0; extra == 'images'
36
39
  Provides-Extra: stub
37
40
  Requires-Dist: cmpnd-deploy; (python_version >= '3.11') and extra == 'stub'
38
41
  Requires-Dist: fastapi>=0.110; extra == 'stub'
@@ -0,0 +1,162 @@
1
+ """Optional image thumbnailing for oversized image inputs/outputs.
2
+
3
+ When a captured field is an oversized inline image data URI, the truncator would
4
+ otherwise discard every pixel and keep a 1000-char base64 fragment. If Pillow is
5
+ installed (`cmpnd[images]`), `image_thumbnail` decodes that data URI, downscales
6
+ it, and returns a small complete JPEG data URI that fits well under the field
7
+ size cap — so the trace page can show what the image was instead of an opaque
8
+ fragment.
9
+
10
+ Everything here is best-effort and must never raise into the capture path:
11
+ Pillow missing, an unparseable URI, a decode failure, or an implausibly large
12
+ source all return ``None``, and the caller falls back to plain-string
13
+ truncation. Only raster formats are handled; SVG and other non-raster or vector
14
+ mimes are refused (an SVG is markup, not pixels to resample).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import binascii
21
+ import io
22
+ import re
23
+
24
+ _THUMBNAIL_JPEG_QUALITY = 70
25
+
26
+ # The thumbnail is sized to a BYTE budget, not a fixed pixel dimension: it should
27
+ # be as large as fits the field's own size budget (the caller passes its max_size)
28
+ # so the preview is as detailed as the truncation system already allows. Starting
29
+ # from the source's NATIVE size, it encodes and — if that overflows the budget —
30
+ # halves the longest edge and re-encodes, until the data URI fits. So an image
31
+ # already small enough is returned at full resolution with no resize, and a large
32
+ # one is scaled down just enough to fit. No pixel dimension is hard-coded anywhere.
33
+
34
+ # Bytes reserved for the envelope around _preview ({"_truncated":…,"_type":"image",
35
+ # "_size_chars":<N>,"_preview":"<uri>"}) so the whole envelope, not just the URI,
36
+ # serializes within the budget. Generous — covers the keys, a large _size_chars,
37
+ # and JSON punctuation.
38
+ _ENVELOPE_HEADROOM = 160
39
+
40
+ # Fallback budget when a caller doesn't pass one. Mirrors models._MAX_FIELD_JSON_SIZE
41
+ # (not imported: models imports this module, so importing it back would cycle);
42
+ # models.truncate_field passes its actual max_size, which is the real bound.
43
+ _DEFAULT_FIELD_BUDGET = 65536
44
+
45
+ # Refuse sources larger than this before decoding: a bound on the synchronous
46
+ # capture-path work, and a first guard against decompression bombs (the pixel
47
+ # ceiling below is the second).
48
+ _MAX_DECODE_BYTES = 8 * 1024 * 1024
49
+
50
+ # A conservative pixel ceiling so a small compressed payload can't expand into a
51
+ # huge bitmap on decode (decompression bomb). Enforced strictly against the
52
+ # declared image size read from the header BEFORE any pixel decode, so an
53
+ # oversized image is rejected outright rather than merely warned about. We never
54
+ # read or write Pillow's process-global Image.MAX_IMAGE_PIXELS, so this ceiling
55
+ # holds regardless of the host's Pillow configuration and never perturbs it.
56
+ _MAX_IMAGE_PIXELS = 24_000_000
57
+
58
+ # Raster mimes we thumbnail. SVG and other vector/non-raster mimes are excluded
59
+ # (an SVG is markup, not pixels to resample).
60
+ _RASTER_MIMES = ("png", "jpeg", "jpg", "gif", "webp", "bmp")
61
+
62
+ # Finds the first embedded raster image data URI and captures its base64 body
63
+ # (up to the first non-base64 char — e.g. the closing quote in JSON). The URI may
64
+ # be bare, or nested inside a wrapper: a captured field is rarely a bare data URI.
65
+ # DSPy serializes a dspy.Image inside custom-type markers as a JSON image_url
66
+ # block — `<<CUSTOM-TYPE-START-IDENTIFIER>>[{"type":"image_url","image_url":
67
+ # {"url":"data:image/…"}}]<<CUSTOM-TYPE-END-IDENTIFIER>>` — so the data URI sits
68
+ # inside an `image_url.url`. Searching handles that, a plain JSON blob, and the
69
+ # bare case alike.
70
+ _DATA_IMAGE_RE = re.compile(r"data:image/(?:" + "|".join(_RASTER_MIMES) + r");base64,([A-Za-z0-9+/]+=*)")
71
+
72
+
73
+ def _extract_image_bytes(value: str) -> bytes | None:
74
+ """Return the decoded bytes of the first embedded raster-image data URI in
75
+ ``value``, or None if there is none or it exceeds the size bound.
76
+ """
77
+ match = _DATA_IMAGE_RE.search(value)
78
+ if match is None:
79
+ return None
80
+ b64 = match.group(1)
81
+ # Cheap size gate on the encoded text before decoding (base64 is ~4/3 the
82
+ # raw size); the decoded check below is the exact one.
83
+ if len(b64) > (_MAX_DECODE_BYTES // 3) * 4 + 4:
84
+ return None
85
+ try:
86
+ raw = base64.b64decode(b64, validate=True)
87
+ except (binascii.Error, ValueError):
88
+ return None
89
+ if len(raw) > _MAX_DECODE_BYTES:
90
+ return None
91
+ return raw
92
+
93
+
94
+ def _encode_jpeg_data_uri(img) -> str:
95
+ """Encode an RGB image as a `data:image/jpeg;base64,…` string."""
96
+ buf = io.BytesIO()
97
+ img.save(buf, format="JPEG", quality=_THUMBNAIL_JPEG_QUALITY, optimize=True)
98
+ return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
99
+
100
+
101
+ def image_thumbnail(value: str, budget: int = _DEFAULT_FIELD_BUDGET) -> str | None:
102
+ """Re-encode an oversized inline image as the largest JPEG data URI that fits
103
+ ``budget`` bytes (the caller's field size cap).
104
+
105
+ Sizes by BYTES, not pixels: it encodes at the source's native size and, while
106
+ that overflows ``budget`` minus the envelope overhead, halves the longest edge
107
+ and re-encodes until it fits — so an already-small image is returned at full
108
+ resolution and a large one is scaled down just enough. Returns a
109
+ `data:image/jpeg;base64,…` string on success, or None when the value contains
110
+ no raster image data URI, Pillow is unavailable, no size fits the budget, or
111
+ anything fails. Never raises.
112
+ """
113
+ raw = _extract_image_bytes(value)
114
+ if raw is None:
115
+ return None
116
+ limit = budget - _ENVELOPE_HEADROOM
117
+ if limit <= len("data:image/jpeg;base64,"):
118
+ return None
119
+ try:
120
+ from PIL import Image # optional dependency (cmpnd[images])
121
+ except ImportError:
122
+ return None
123
+ try:
124
+ with Image.open(io.BytesIO(raw)) as opened:
125
+ # Image.open is lazy: it reads only the header (enough for .size),
126
+ # not the pixels. Reject on the declared size BEFORE load() so a
127
+ # small compressed payload that claims a huge bitmap never decodes at
128
+ # full resolution. This is our own strict ceiling; we never touch
129
+ # Pillow's Image.MAX_IMAGE_PIXELS global.
130
+ w, h = opened.size
131
+ if w * h > _MAX_IMAGE_PIXELS:
132
+ return None
133
+ opened.load()
134
+ # JPEG has no alpha: flatten transparency / palette onto white so
135
+ # a PNG/GIF with an alpha channel doesn't fail the encode.
136
+ if opened.mode in ("RGBA", "LA", "P"):
137
+ rgba = opened.convert("RGBA")
138
+ background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
139
+ base = Image.alpha_composite(background, rgba).convert("RGB")
140
+ elif opened.mode != "RGB":
141
+ base = opened.convert("RGB")
142
+ else:
143
+ base = opened.copy()
144
+ # Encode at native size; while it overflows the budget, halve the
145
+ # longest edge and re-encode. Returns the first (largest) size that
146
+ # fits — native itself when the image is already small enough.
147
+ best: str | None = None
148
+ edge = max(base.size)
149
+ while edge >= 1:
150
+ candidate = base.copy()
151
+ candidate.thumbnail((edge, edge)) # only ever downscales
152
+ uri = _encode_jpeg_data_uri(candidate)
153
+ if len(uri) <= limit:
154
+ best = uri
155
+ break
156
+ edge //= 2
157
+ except Exception:
158
+ # Any decode/resize/encode failure — corrupt bytes or a header that
159
+ # can't be read, an unsupported sub-format — degrades to no thumbnail.
160
+ # Instrumentation must not break the host program.
161
+ return None
162
+ return best
@@ -9,7 +9,7 @@ from enum import Enum
9
9
  from typing import Any
10
10
  from uuid import UUID
11
11
 
12
- from . import identity
12
+ from . import identity, imaging
13
13
 
14
14
 
15
15
  def _safe_serialize(value: Any, _stack: set[int] | None = None) -> Any:
@@ -169,6 +169,20 @@ def truncate_field(value: Any, max_size: int = _MAX_FIELD_JSON_SIZE, depth: int
169
169
  # bytes (exporter.py); a raw char count or UTF-8 byte count both
170
170
  # undercount that. _size_chars stays the char count.
171
171
  if len(json.dumps(value)) > max_size:
172
+ # An oversized inline image would otherwise lose every pixel to the
173
+ # 1000-char fragment below. If the value is a raster image data URI
174
+ # and Pillow is available, keep a small COMPLETE thumbnail in the
175
+ # _preview slot and flip _type to "image" so the reader can render it.
176
+ # Returns None (→ plain-string truncation) for non-images, no Pillow,
177
+ # or any failure, so this path is purely additive and never raises.
178
+ thumbnail = imaging.image_thumbnail(value, max_size)
179
+ if thumbnail is not None:
180
+ return {
181
+ "_truncated": True,
182
+ "_type": "image",
183
+ "_size_chars": len(value),
184
+ "_preview": thumbnail,
185
+ }
172
186
  return {
173
187
  "_truncated": True,
174
188
  "_type": "string",
@@ -245,6 +245,7 @@ Every envelope carries `_truncated: true`, a `_type`, and a `_preview` (serve re
245
245
  - **list** → `{_truncated, _type: "list", _total_items, _shown_items, _size_bytes, _preview}` — `_preview` is the first ≤10 items, each recursively truncated (`_shown_items == len(_preview)`); an oversized first item survives as its own nested envelope.
246
246
  - **dict** (nested, over the key ceiling `_MAX_DICT_KEYS` = 50) → `{_truncated, _type: "dict", _total_keys, _shown_keys, _preview}` — `_preview` holds the first K kept keys, each recursively truncated. Kept keys nest under `_preview` (not inline) so a user key named like a marker can't be clobbered.
247
247
  - **other scalar** → `{_truncated, _type: <typename>, _size_bytes | _size_chars, _preview}`.
248
+ - **image** → `{_truncated, _type: "image", _size_chars, _preview}` — when an oversized string is a raster image data URI (`data:image/<png|jpeg|gif|webp|bmp>;base64,…`) **and** the optional `cmpnd[images]` extra (Pillow) is installed, `_preview` holds a *complete* small JPEG thumbnail — sized to fit the field's byte budget (as large as fits, scaled down from the source's native size, with no fixed pixel dimension) — instead of a 1000-char fragment, and `_type` is `"image"`. Emitted only on a successful thumbnail; without Pillow, or on any failure, the field degrades to the plain **string** envelope above (byte-identical to before). The `_type` is the render gate: serve shows `_preview` as an `<img>` only for `_type:"image"` (a `string`-envelope fragment also starts with `data:image/` but is incomplete, so it stays text). Thumbnailing lives in `python-sdk/cmpnd/imaging.py`; it is import-guarded and never raises into the capture path.
248
249
 
249
250
  Dict handling: **value-aware allocation** keeps every key (small values whole, the remaining budget split across the large ones — a missing key would read as "not captured", the symptom this replaced). Two things can still drop keys, each recorded via the `_type:"dict"` envelope: the **key ceiling** (`_MAX_DICT_KEYS`), which applies to nested dicts only — the root is exempt from it; and the **whole-payload guard** — since `max_size` bounds the whole payload, after assembly any dict (root included) sheds trailing keys until it serializes within `max_size`. So the root keeps all its fields when they fit and is trimmed (with `_total_keys`/`_shown_keys`) when the payload still exceeds the cap. The callback capture path (`helpers.clean_inputs`) truncates each field value at depth 1, so a nested dict field value caps consistently with `SpanBuilder.to_dict` (where the whole inputs dict is the depth-0 root and field values are depth 1).
250
251
 
@@ -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.7.2"
8
+ version = "0.8.0"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -46,6 +46,13 @@ dependencies = [
46
46
  ]
47
47
 
48
48
  [project.optional-dependencies]
49
+ # Image thumbnailing for oversized image inputs/outputs. Optional and
50
+ # import-guarded: without it, an oversized image degrades to today's text
51
+ # preview. Pillow is heavyweight and most instrumentation captures no images, so
52
+ # it is not a hard dependency.
53
+ images = [
54
+ "pillow>=10.0",
55
+ ]
49
56
  dev = [
50
57
  "pytest>=8.0",
51
58
  "pytest-asyncio>=0.23",
@@ -62,6 +69,9 @@ dev = [
62
69
  # unconditionally so it rode in transitively; dspy 3.3.0 moved numpy behind a
63
70
  # `numpy` extra, so declare it directly as the test dependency it always was.
64
71
  "numpy>=1.26",
72
+ # Exercises the image-thumbnail path (the `images` extra) so the positive
73
+ # branch is covered in CI, not just the Pillow-absent fallback.
74
+ "pillow>=10.0",
65
75
  ]
66
76
  # Deps for `deploy/deploy/stub/`, the local FastAPI server that emulates
67
77
  # the AWS deploy stack. The stub itself ships in the cmpnd-deploy
@@ -215,10 +215,18 @@ class TestOptimizeGepaDeployDetail:
215
215
 
216
216
  # The producer ran a cmpnd OptimizationTracker: the run lands full detail
217
217
  # (>= the seed candidate), not just a summary header. Asserted first so a
218
- # producer/tracker failure is distinguished from a trace-stamping one. The
219
- # watcher writes the run header + candidates in one transaction, so once
220
- # the run is visible the candidates are too.
221
- candidates = server_client.get_optimization_candidates(run_id).get("candidates", [])
218
+ # producer/tracker failure is distinguished from a trace-stamping one.
219
+ #
220
+ # Polled, because a run is addressable BEFORE it has any detail: serve
221
+ # writes the header at initiate with status "running" so the run is listed
222
+ # and its detail URL resolves for its whole duration (CMP-412), and the
223
+ # candidates arrive later, when the completion watcher settles it. The
224
+ # search above therefore returns the run while the candidate set is still
225
+ # legitimately empty, and a single read here races the watcher.
226
+ def _find_candidates():
227
+ return server_client.get_optimization_candidates(run_id).get("candidates", [])
228
+
229
+ candidates = _poll(_find_candidates)
222
230
  assert candidates, f"GEPA deploy run {run_id} landed a header but no candidate detail (CMP-323)"
223
231
 
224
232
  # CMP-323/CMP-318 activation: the Lambda's tracker stamps the run id onto
@@ -0,0 +1,193 @@
1
+ """Image-thumbnail path for oversized image inputs/outputs.
2
+
3
+ Covers the imaging helper directly and its integration into truncate_field: an
4
+ oversized inline image becomes a `_type:"image"` envelope carrying a small
5
+ complete thumbnail in `_preview`, while non-images and the Pillow-absent case
6
+ fall back to today's `_type:"string"` truncation.
7
+ """
8
+
9
+ import base64
10
+ import io
11
+ import os
12
+
13
+ import pytest
14
+
15
+ from cmpnd import imaging
16
+ from cmpnd.models import _MAX_FIELD_JSON_SIZE, truncate_field
17
+
18
+ PIL_Image = pytest.importorskip("PIL.Image")
19
+
20
+
21
+ def _data_uri(img, fmt="PNG") -> str:
22
+ buf = io.BytesIO()
23
+ img.save(buf, format=fmt)
24
+ mime = "jpeg" if fmt.upper() == "JPEG" else fmt.lower()
25
+ return f"data:image/{mime};base64," + base64.b64encode(buf.getvalue()).decode("ascii")
26
+
27
+
28
+ def _noise_image(px: int):
29
+ # Random pixels barely compress, so the encoded data URI reliably clears the
30
+ # 64 KB field cap (a flat image would compress under it).
31
+ return PIL_Image.frombytes("RGB", (px, px), os.urandom(px * px * 3))
32
+
33
+
34
+ def _oversized_png_uri() -> str:
35
+ uri = _data_uri(_noise_image(256))
36
+ assert len(uri) > _MAX_FIELD_JSON_SIZE, "fixture must exceed the field cap"
37
+ return uri
38
+
39
+
40
+ def _decoded_edge(data_uri: str) -> int:
41
+ with PIL_Image.open(io.BytesIO(base64.b64decode(data_uri.split(",", 1)[1]))) as im:
42
+ assert im.format == "JPEG"
43
+ return max(im.size)
44
+
45
+
46
+ def test_thumbnail_is_valid_jpeg_within_budget():
47
+ uri = _oversized_png_uri()
48
+ thumb = imaging.image_thumbnail(uri)
49
+ assert thumb is not None
50
+ assert thumb.startswith("data:image/jpeg;base64,")
51
+ assert len(thumb) <= 65536 # fits the default field budget
52
+ assert len(thumb) < len(uri) # smaller than the original data URI
53
+ _decoded_edge(thumb) # decodes to a real JPEG
54
+
55
+
56
+ def test_thumbnail_sizes_to_byte_budget():
57
+ # A smooth gradient compresses predictably yet grows with pixel size, so a
58
+ # bigger byte budget yields a physically larger thumbnail.
59
+ gradient = PIL_Image.linear_gradient("L").convert("RGB").resize((1024, 1024))
60
+ uri = _data_uri(gradient)
61
+ small = imaging.image_thumbnail(uri, budget=8000)
62
+ large = imaging.image_thumbnail(uri, budget=64000)
63
+ assert small is not None and large is not None
64
+ assert len(small) <= 8000
65
+ assert len(large) <= 64000
66
+ # Sizing is byte-driven, not a fixed pixel cap: more budget -> more pixels.
67
+ assert _decoded_edge(large) > _decoded_edge(small)
68
+
69
+
70
+ def test_thumbnail_keeps_native_size_when_it_fits():
71
+ # An image whose JPEG already fits the budget is returned at full resolution,
72
+ # not resized — the scale-down-from-native path handles this for free.
73
+ img = PIL_Image.linear_gradient("L").convert("RGB").resize((96, 96))
74
+ thumb = imaging.image_thumbnail(_data_uri(img), budget=64000)
75
+ assert thumb is not None
76
+ assert _decoded_edge(thumb) == 96
77
+
78
+
79
+ def test_thumbnail_flattens_alpha_png():
80
+ # An RGBA source must not fail the JPEG encode (JPEG has no alpha).
81
+ rgba = PIL_Image.frombytes("RGBA", (256, 256), os.urandom(256 * 256 * 4))
82
+ thumb = imaging.image_thumbnail(_data_uri(rgba))
83
+ assert thumb is not None and thumb.startswith("data:image/jpeg;base64,")
84
+
85
+
86
+ @pytest.mark.parametrize(
87
+ "value",
88
+ [
89
+ "not a data uri at all",
90
+ "data:text/plain;base64,aGVsbG8=",
91
+ "data:image/svg+xml;base64,PHN2Zy8+", # vector, refused
92
+ "data:image/png,notbase64", # not a base64 data URI
93
+ "data:image/png;base64,@@@not-base64@@@",
94
+ ],
95
+ )
96
+ def test_thumbnail_returns_none_for_non_raster_or_garbage(value):
97
+ assert imaging.image_thumbnail(value) is None
98
+
99
+
100
+ def test_thumbnail_refuses_oversized_source(monkeypatch):
101
+ monkeypatch.setattr(imaging, "_MAX_DECODE_BYTES", 1024)
102
+ assert imaging.image_thumbnail(_oversized_png_uri()) is None
103
+
104
+
105
+ def test_thumbnail_pixel_ceiling_rejects_before_decode(monkeypatch):
106
+ # A tiny pixel ceiling must reject a normal 256x256 (65536 px) image: the
107
+ # size is read from the header and checked before any pixel decode.
108
+ monkeypatch.setattr(imaging, "_MAX_IMAGE_PIXELS", 100)
109
+ assert imaging.image_thumbnail(_oversized_png_uri()) is None
110
+
111
+
112
+ def test_thumbnail_never_mutates_pillow_global():
113
+ # The helper enforces its own ceiling and must never touch Pillow's
114
+ # process-global MAX_IMAGE_PIXELS — neither on the success path nor on the
115
+ # reject path — so it can't race concurrent captures.
116
+ before = PIL_Image.MAX_IMAGE_PIXELS
117
+ assert imaging.image_thumbnail(_oversized_png_uri()) is not None
118
+ assert PIL_Image.MAX_IMAGE_PIXELS == before
119
+
120
+ with pytest.MonkeyPatch.context() as mp:
121
+ mp.setattr(imaging, "_MAX_IMAGE_PIXELS", 100)
122
+ assert imaging.image_thumbnail(_oversized_png_uri()) is None
123
+ assert PIL_Image.MAX_IMAGE_PIXELS == before
124
+
125
+
126
+ def test_thumbnail_none_without_pillow(monkeypatch):
127
+ # Simulate Pillow not installed: make the in-function `from PIL import Image`
128
+ # raise ImportError, so the helper returns None (today's behavior).
129
+ import builtins
130
+
131
+ real_import = builtins.__import__
132
+
133
+ def fake_import(name, *args, **kwargs):
134
+ if name == "PIL" or name.startswith("PIL."):
135
+ raise ImportError("simulated: no pillow")
136
+ return real_import(name, *args, **kwargs)
137
+
138
+ monkeypatch.setattr(builtins, "__import__", fake_import)
139
+ assert imaging.image_thumbnail(_oversized_png_uri()) is None
140
+
141
+
142
+ def test_truncate_field_emits_image_envelope():
143
+ uri = _oversized_png_uri()
144
+ result = truncate_field(uri)
145
+ assert result["_truncated"] is True
146
+ assert result["_type"] == "image"
147
+ assert result["_size_chars"] == len(uri)
148
+ assert result["_preview"].startswith("data:image/jpeg;base64,")
149
+ # The whole envelope still fits the field cap (thumbnail is a few KB).
150
+ import json
151
+
152
+ assert len(json.dumps(result)) <= _MAX_FIELD_JSON_SIZE
153
+
154
+
155
+ def test_truncate_field_falls_back_to_string_without_thumbnail(monkeypatch):
156
+ # No thumbnail (no Pillow / failure) => today's string truncation, byte-identical.
157
+ monkeypatch.setattr("cmpnd.models.imaging.image_thumbnail", lambda *a, **k: None)
158
+ uri = _oversized_png_uri()
159
+ result = truncate_field(uri)
160
+ assert result["_type"] == "string"
161
+ assert result["_preview"] == uri[:1000]
162
+
163
+
164
+ def test_truncate_field_non_image_string_unchanged():
165
+ result = truncate_field("x" * (_MAX_FIELD_JSON_SIZE + 10))
166
+ assert result["_type"] == "string"
167
+ assert result["_preview"] == "x" * 1000
168
+
169
+
170
+ def _dspy_image_wrapper(data_uri: str) -> str:
171
+ # Mirrors how DSPy serializes a dspy.Image (adapters/types/base_type.py): the
172
+ # data URI is nested inside a JSON image_url block wrapped in custom-type
173
+ # markers, so the captured leaf is NOT a bare data URI.
174
+ import json
175
+
176
+ body = json.dumps([{"type": "image_url", "image_url": {"url": data_uri}}])
177
+ return f"<<CUSTOM-TYPE-START-IDENTIFIER>>{body}<<CUSTOM-TYPE-END-IDENTIFIER>>"
178
+
179
+
180
+ def test_thumbnail_extracts_from_dspy_wrapper():
181
+ wrapped = _dspy_image_wrapper(_oversized_png_uri())
182
+ thumb = imaging.image_thumbnail(wrapped)
183
+ assert thumb is not None
184
+ assert thumb.startswith("data:image/jpeg;base64,")
185
+
186
+
187
+ def test_truncate_field_image_envelope_from_dspy_wrapper():
188
+ wrapped = _dspy_image_wrapper(_oversized_png_uri())
189
+ assert len(wrapped) > _MAX_FIELD_JSON_SIZE
190
+ result = truncate_field(wrapped)
191
+ assert result["_type"] == "image"
192
+ assert result["_preview"].startswith("data:image/jpeg;base64,")
193
+ assert result["_size_chars"] == len(wrapped)