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.
- {cmpnd-0.7.2 → cmpnd-0.8.0}/PKG-INFO +4 -1
- cmpnd-0.8.0/cmpnd/imaging.py +162 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/models.py +15 -1
- {cmpnd-0.7.2 → cmpnd-0.8.0}/docs/sdk-instrumentation.md +1 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/pyproject.toml +11 -1
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimize_loop.py +12 -4
- cmpnd-0.8.0/tests/test_imaging.py +193 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/uv.lock +102 -3
- {cmpnd-0.7.2 → cmpnd-0.8.0}/.gitignore +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/CLAUDE.md +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/README.md +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/_gepa_patch.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/_program_patch.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/_rlm_patch.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/callback.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/api_client.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/admin.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/auth.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/datasets.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/deployments.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/evals.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/login.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/optimizations.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/org.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/programs.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/top.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/commands/traces.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/credentials.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/shell.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/trace_render.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/cli/verbs.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/configuration.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/context.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/dataset_sync.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/datasets.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/decorators.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/deployment.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/eval_handler.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/execution.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/exporter.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/exporter_http.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/helpers.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/identity.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/decode.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/encode.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/hash.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/program.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/reflection.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/types.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/ir/xxh64.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimization.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/gepa.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/gepa_callback.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/resume.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/optimizers/tracker.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/cmpnd/packaging.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/docs/2026-04-02-code-review.md +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/examples/local_ollama.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/scripts/capture_review_run.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/scripts/fixtures/review_run.json.gz +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/scripts/gepa_resume_bug.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/scripts/seed_review_run.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/__init__.pyi +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/primitives/__init__.pyi +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/primitives/prediction.pyi +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/utils/__init__.pyi +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/stubs/dspy/utils/callback.pyi +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/_exporter_helpers.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/_identity_helpers.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/committee_task.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/conftest.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/data/committee_train.ndjson.gz +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/data/committee_val.ndjson.gz +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/fake_lm.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/helpers.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_capture_prompts.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_composite_module.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_dataset_auto_creation.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_deploy_execute.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_deploy_programs.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_edge_cases.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_example_hash.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_gepa_optimization.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_lm_accuracy.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_module_tracing.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimization_lineage.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimization_metric_identity.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimize_failures.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimize_review_committee.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_parallel_export.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_project_attribution.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_react_tool_spans.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_save_load.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_trace_structure.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/e2e/test_user_attribution.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/01_predict_qa.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/04_predict_richer_types.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/06_multichaincomparison.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/07_program_of_thought.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/08_opaque_module.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/10_react.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/11_refine.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/12_retrieve.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/13_knn.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/14_parallel.json +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/identity_vectors/README.md +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/conftest.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/test_cli_deploy_smoketest.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/test_logs_renders_traces.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/test_optimizing_progress.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/integration/test_unhappy_path.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/conftest.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/seed_parity.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_auth.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_datasets.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_deployments.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_evals.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_health.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_modules.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_optimizations.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_programs.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_signatures.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_trace_streaming.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/parity/test_traces.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/_provider.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/_reliability.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/mint_bedrock_token.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_deploy_programs.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_multi_client_stress.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_optimize_client_committee.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_optimize_loop.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_optimize_loop_wasm.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_optimize_stress.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_provider_roles.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_real_provider_twins.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_reliability.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_reliability_classify.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/stress/test_rlm_wasm.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_callback.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/conftest.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_admin.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_api_client.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_auth.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_credentials.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_datasets.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_deployments.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_evals.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_login.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_main.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_optimizations.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_org.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_parity.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_programs.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_shell.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_top.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cli/test_traces.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/__init__.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/conftest.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/test_edge_cases.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/test_hash_consistency.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_clustering/test_type_conversion.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_cmp551_grouping_identity.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_configuration.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_context.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_context_propagation.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_dataset_polling.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_dataset_sync.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_datasets.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_decorators.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_deploy.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_eval.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_eval_start.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_exception_paths.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_execute.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_exporter.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_exporter_real.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_fake_lm.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_gepa_callback.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_gepa_integration.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_gepa_resume.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_identity.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_import_weight.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_integration.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_ir_encoder_properties.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_ir_golden_vectors.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_ir_roundtrip.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_models.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimization_progress.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimization_start.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimize.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimize_lift.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_optimize_terminal_failure.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_packaging.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_packaging_callable_source.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_packaging_program_source.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_program_lineage_persist.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_project_payload.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_reflector_properties.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_reflector_smoke.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_repro_reactv2_bugs.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_rlm_patch.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_schemas.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_seed_review_run.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_stats_export.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_trace_render.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_tracked_gepa.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_tracker_enrich.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_truncation.py +0 -0
- {cmpnd-0.7.2 → cmpnd-0.8.0}/tests/test_xxh64.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
|
|
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)
|