x4d-devkit 0.51.0rc3__tar.gz → 0.52.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 (169) hide show
  1. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/PKG-INFO +1 -1
  2. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/pyproject.toml +1 -1
  3. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_annotation_document_hash.py +23 -0
  4. x4d_devkit-0.52.0/tests/test_image_conditional_rules.py +328 -0
  5. x4d_devkit-0.52.0/tests/test_image_enum_set.py +178 -0
  6. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_parse_equivalence.py +3 -2
  7. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_sdk_api.py +1 -1
  8. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/annotation_contract.py +12 -2
  9. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_clip.py +123 -1
  10. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_schema.py +167 -5
  11. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_schema_upgrade.py +7 -0
  12. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/work_session.py +2 -1
  13. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit.egg-info/PKG-INFO +1 -1
  14. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit.egg-info/SOURCES.txt +2 -0
  15. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/LICENSE +0 -0
  16. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/README.md +0 -0
  17. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/setup.cfg +0 -0
  18. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_accounts.py +0 -0
  19. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_annotation_contract.py +0 -0
  20. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_annotation_reference.py +0 -0
  21. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_assets_client.py +0 -0
  22. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_boundary.py +0 -0
  23. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_box.py +0 -0
  24. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_build_info.py +0 -0
  25. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_category_audit.py +0 -0
  26. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_clean_basemap.py +0 -0
  27. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_clean_basemap_client.py +0 -0
  28. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_cli_auth.py +0 -0
  29. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_client_json_contract.py +0 -0
  30. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_client_projects.py +0 -0
  31. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_clip_archives.py +0 -0
  32. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_contract_validation.py +0 -0
  33. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_coordinate_frames.py +0 -0
  34. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_current_state.py +0 -0
  35. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_current_state_identity_consumers_example.py +0 -0
  36. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_dataset_contract.py +0 -0
  37. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_dataset_identity.py +0 -0
  38. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_detection_eval.py +0 -0
  39. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_evaluate.py +0 -0
  40. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_export_bundle.py +0 -0
  41. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_clip.py +0 -0
  42. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_export.py +0 -0
  43. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_geometry.py +0 -0
  44. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_groups.py +0 -0
  45. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_preannotation.py +0 -0
  46. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_reference.py +0 -0
  47. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_review.py +0 -0
  48. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_schema_upgrade.py +0 -0
  49. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_image_work_session.py +0 -0
  50. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_inference_input.py +0 -0
  51. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_inference_schemas.py +0 -0
  52. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_inference_service.py +0 -0
  53. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_label_schemas.py +0 -0
  54. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_lidar_materialization.py +0 -0
  55. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_loader.py +0 -0
  56. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_matching.py +0 -0
  57. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_metrics.py +0 -0
  58. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_models.py +0 -0
  59. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_platform_pose.py +0 -0
  60. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_preannotation_client.py +0 -0
  61. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_project_discovery.py +0 -0
  62. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_reconstruction_client.py +0 -0
  63. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_release_gate.py +0 -0
  64. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_roadmark_preview.py +0 -0
  65. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_semantic_scene_authoring.py +0 -0
  66. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_semantic_scene_client.py +0 -0
  67. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_semantic_scene_contract.py +0 -0
  68. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_semantic_scene_independent.py +0 -0
  69. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_semantic_scene_native_clip.py +0 -0
  70. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_source_attributes.py +0 -0
  71. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_time_contract.py +0 -0
  72. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_token.py +0 -0
  73. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_tracking_eval.py +0 -0
  74. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_training_identity.py +0 -0
  75. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_transform.py +0 -0
  76. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_transform_chain.py +0 -0
  77. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_validation.py +0 -0
  78. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_vector_map.py +0 -0
  79. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_vector_map_geometry.py +0 -0
  80. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_vector_map_session.py +0 -0
  81. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_work_session.py +0 -0
  82. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/tests/test_work_session_validation_reuse.py +0 -0
  83. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/__init__.py +0 -0
  84. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/_image_contract.py +0 -0
  85. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/accounts.py +0 -0
  86. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/annotation_reference.py +0 -0
  87. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/audit.py +0 -0
  88. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/build_info.py +0 -0
  89. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/clean_basemap.py +0 -0
  90. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/cli.py +0 -0
  91. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/__init__.py +0 -0
  92. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/_origin.py +0 -0
  93. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/accounts.py +0 -0
  94. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/annotation_reference.py +0 -0
  95. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/assets.py +0 -0
  96. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/bags.py +0 -0
  97. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/capabilities.py +0 -0
  98. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/checkpoints.py +0 -0
  99. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/clean_basemap.py +0 -0
  100. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/client.py +0 -0
  101. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/clip_work_sessions.py +0 -0
  102. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/clips.py +0 -0
  103. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/dataset_identity.py +0 -0
  104. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/image_clips.py +0 -0
  105. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/image_exports.py +0 -0
  106. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/image_preannotations.py +0 -0
  107. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/image_review.py +0 -0
  108. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/image_schemas.py +0 -0
  109. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/image_work_sessions.py +0 -0
  110. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/label_schemas.py +0 -0
  111. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/preannotation.py +0 -0
  112. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/projects.py +0 -0
  113. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/reconstruction.py +0 -0
  114. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/semantic_scene.py +0 -0
  115. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/semantic_scene_authoring.py +0 -0
  116. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/services.py +0 -0
  117. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/client/test_sets.py +0 -0
  118. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/consumer_identity.py +0 -0
  119. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/coordinate_contract.py +0 -0
  120. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/core/__init__.py +0 -0
  121. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/core/loader.py +0 -0
  122. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/core/models.py +0 -0
  123. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/core/token.py +0 -0
  124. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/core/transform.py +0 -0
  125. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/current_state.py +0 -0
  126. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/dataset_contract.py +0 -0
  127. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/dataset_identity.py +0 -0
  128. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/eval/__init__.py +0 -0
  129. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/eval/api.py +0 -0
  130. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/eval/box.py +0 -0
  131. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/eval/detection.py +0 -0
  132. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/eval/matching.py +0 -0
  133. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/eval/metrics.py +0 -0
  134. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/eval/tracking.py +0 -0
  135. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/export_bundle.py +0 -0
  136. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_export.py +0 -0
  137. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_geometry.py +0 -0
  138. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_groups.py +0 -0
  139. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_preannotation.py +0 -0
  140. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_reference.py +0 -0
  141. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_review.py +0 -0
  142. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/image_work_session.py +0 -0
  143. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/inference/__init__.py +0 -0
  144. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/inference/input.py +0 -0
  145. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/inference/models.py +0 -0
  146. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/inference/schemas.py +0 -0
  147. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/inference/service.py +0 -0
  148. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/inference/tasks.py +0 -0
  149. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/json_contract.py +0 -0
  150. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/label_schema.py +0 -0
  151. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/lidar_materialization.py +0 -0
  152. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/materialize.py +0 -0
  153. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/project_discovery.py +0 -0
  154. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/roadmark_preview.py +0 -0
  155. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/semantic_scene_authoring.py +0 -0
  156. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/semantic_scene_contract.py +0 -0
  157. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/time_contract.py +0 -0
  158. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/training_identity.py +0 -0
  159. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/validation/__init__.py +0 -0
  160. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/validation/contract.py +0 -0
  161. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/validation/platform_pose.py +0 -0
  162. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/validation/report.py +0 -0
  163. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/validation/validator.py +0 -0
  164. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/vector_map.py +0 -0
  165. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit/vector_map_geometry.py +0 -0
  166. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  167. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  168. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit.egg-info/requires.txt +0 -0
  169. {x4d_devkit-0.51.0rc3 → x4d_devkit-0.52.0}/x4d_devkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.51.0rc3
3
+ Version: 0.52.0
4
4
  Summary: Official X-4D external SDK and CLI for datasets, platform APIs, training bridges, and automation
5
5
  Author: windzu
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "x4d-devkit"
7
- version = "0.51.0rc3"
7
+ version = "0.52.0"
8
8
  description = "Official X-4D external SDK and CLI for datasets, platform APIs, training bridges, and automation"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -4,11 +4,13 @@ import dataclasses
4
4
  import hashlib
5
5
  import random
6
6
 
7
+ import pytest
7
8
  import rfc8785
8
9
 
9
10
  from tests.test_annotation_contract import _annotation
10
11
  from x4d_devkit.annotation_contract import (
11
12
  ANNOTATION_CONTRACT_VERSION,
13
+ AnnotationContractError,
12
14
  ClipAnnotationDocument,
13
15
  build_clip_annotations,
14
16
  parse_clip_annotations,
@@ -86,3 +88,24 @@ def test_document_hash_is_not_part_of_document_equality():
86
88
  assert built == parsed == plain
87
89
  assert "_document_hash" not in {field.name for field in dataclasses.fields(built)}
88
90
  assert built.to_dict()["contract_version"] == ANNOTATION_CONTRACT_VERSION
91
+
92
+
93
+ def test_parse_reuses_only_documents_this_module_verified():
94
+ built = build_clip_annotations("clip-a", "base_link", _random_rows(random.Random(3), 4))
95
+ assert parse_clip_annotations(built) is built
96
+ assert parse_clip_annotations(parse_clip_annotations(built.to_dict())) is not built
97
+
98
+ forged = ClipAnnotationDocument(
99
+ clip_id=built.clip_id,
100
+ annotation_frame_id=built.annotation_frame_id,
101
+ annotation_revision="sha256:" + "0" * 64,
102
+ annotations=built.annotations,
103
+ )
104
+ forged.document_hash() # caching the hash must not mark the document verified
105
+ with pytest.raises(AnnotationContractError) as error:
106
+ parse_clip_annotations(forged)
107
+ assert error.value.code == "annotation_revision_mismatch"
108
+
109
+ replaced = dataclasses.replace(built, annotation_revision="sha256:" + "1" * 64)
110
+ with pytest.raises(AnnotationContractError):
111
+ parse_clip_annotations(replaced)
@@ -0,0 +1,328 @@
1
+ """Closed conditional attribute rules, English terms and description coverage (#220)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+
7
+ import pytest
8
+
9
+ from x4d_devkit._image_contract import ImageContractError, fingerprint
10
+ from x4d_devkit.image_clip import build_image_identity, parse_image_clip
11
+ from x4d_devkit.image_schema import parse_image_schema_body
12
+ from tests.image_fixtures import clip_payload, schema_body
13
+
14
+
15
+ def enum(attribute_id, values, **extra):
16
+ return {
17
+ "id": attribute_id,
18
+ "display_name": attribute_id,
19
+ "type": "enum",
20
+ "required": False,
21
+ "scopes": ["object"],
22
+ "values": [{"id": value, "display_name": value} for value in values],
23
+ **extra,
24
+ }
25
+
26
+
27
+ def boolean(attribute_id):
28
+ return {
29
+ "id": attribute_id,
30
+ "display_name": attribute_id,
31
+ "type": "boolean",
32
+ "required": False,
33
+ "scopes": ["object"],
34
+ "values": [],
35
+ }
36
+
37
+
38
+ def directions():
39
+ return {
40
+ "id": "occlusion_directions",
41
+ "display_name": "occlusion directions",
42
+ "type": "enum_set",
43
+ "required": False,
44
+ "scopes": ["object"],
45
+ "values": [
46
+ {"id": side, "display_name": side} for side in ("top", "bottom", "left", "right")
47
+ ],
48
+ "min_items": 1,
49
+ "max_items": 4,
50
+ }
51
+
52
+
53
+ SIGNAL_RULES = [
54
+ {
55
+ "id": "head_facts",
56
+ "attributes": ["head_type", "occlusion"],
57
+ "required": ["head_type", "occlusion"],
58
+ },
59
+ {
60
+ "id": "arrow",
61
+ "when": {"attribute": "head_type", "equals": "arrow"},
62
+ "attributes": ["arrow_direction"],
63
+ "required": ["arrow_direction"],
64
+ },
65
+ {
66
+ "id": "damaged",
67
+ "when": {"attribute": "head_type", "in": ["countdown", "countdown_damaged"]},
68
+ "attributes": ["digit_unclear"],
69
+ "required": ["digit_unclear"],
70
+ },
71
+ {
72
+ "id": "digit_countdown",
73
+ "when": {"attribute": "head_type", "equals": "countdown"},
74
+ "attributes": ["digit"],
75
+ "required": ["digit"],
76
+ },
77
+ {
78
+ "id": "digit_damaged_known",
79
+ "when": {
80
+ "all": [
81
+ {"attribute": "head_type", "equals": "countdown_damaged"},
82
+ {"attribute": "digit_unclear", "equals": False},
83
+ ]
84
+ },
85
+ "attributes": ["digit"],
86
+ "required": ["digit"],
87
+ },
88
+ {
89
+ "id": "digit_optional_when_unclear",
90
+ "when": {
91
+ "all": [
92
+ {"attribute": "head_type", "equals": "countdown_damaged"},
93
+ {"attribute": "digit_unclear", "equals": True},
94
+ ]
95
+ },
96
+ "attributes": ["digit"],
97
+ "required": [],
98
+ },
99
+ {
100
+ "id": "occluded",
101
+ "when": {"attribute": "occlusion", "equals": True},
102
+ "attributes": ["occlusion_directions", "occlusion_ratio"],
103
+ "required": ["occlusion_directions", "occlusion_ratio"],
104
+ },
105
+ ]
106
+
107
+
108
+ def signal_category(rules=None):
109
+ return {
110
+ "id": "box",
111
+ "display_name": "Signal head",
112
+ "geometry_type": "box_2d",
113
+ "color": "#112233",
114
+ "attributes": [
115
+ enum("head_type", ["round", "arrow", "countdown", "countdown_damaged"]),
116
+ enum("arrow_direction", ["up", "left"]),
117
+ enum("digit", [str(digit) for digit in range(10)]),
118
+ boolean("digit_unclear"),
119
+ boolean("occlusion"),
120
+ directions(),
121
+ enum("occlusion_ratio", ["low", "medium", "high"]),
122
+ ],
123
+ "rules": copy.deepcopy(SIGNAL_RULES if rules is None else rules),
124
+ }
125
+
126
+
127
+ def body(category=None, description_fields=None):
128
+ value = schema_body()
129
+ value["categories"][1] = category or signal_category()
130
+ if description_fields is not None:
131
+ value["task_views"][1]["category_ids"] = ["box"]
132
+ value["task_views"][1]["description_fields"] = description_fields
133
+ return value
134
+
135
+
136
+ def box(object_id, attributes):
137
+ return {
138
+ "id": object_id,
139
+ "image_id": "image_a",
140
+ "view_id": "objects",
141
+ "category_id": "box",
142
+ "geometry": {
143
+ "geometry_type": "box_2d",
144
+ "box": {"x_min": 10, "y_min": 20, "x_max": 30, "y_max": 40},
145
+ },
146
+ "attributes": attributes,
147
+ "attribute_ranges": [],
148
+ }
149
+
150
+
151
+ def clip(schema, objects, *, objects_state="complete", descriptions=(), text_state="unprocessed"):
152
+ value = clip_payload()
153
+ value["label_schema"]["body"] = schema
154
+ value["label_schema"]["identity"]["fingerprint"] = fingerprint(schema)
155
+ value["image_annotation"]["objects"] = objects
156
+ value["image_annotation"]["descriptions"] = list(descriptions)
157
+ states = {"objects": objects_state, "semantics": text_state}
158
+ for row in value["image_annotation"]["completion"]:
159
+ row["state"] = states[row["view_id"]]
160
+ value["identity"] = build_image_identity(value)
161
+ return parse_image_clip(value)
162
+
163
+
164
+ def head(**attributes):
165
+ return box("head_a", {"occlusion": False, **attributes})
166
+
167
+
168
+ @pytest.mark.parametrize(
169
+ "attributes",
170
+ [
171
+ {"head_type": "round"},
172
+ {"head_type": "arrow", "arrow_direction": "left"},
173
+ {"head_type": "countdown", "digit_unclear": False, "digit": "4"},
174
+ {"head_type": "countdown_damaged", "digit_unclear": True},
175
+ {"head_type": "countdown_damaged", "digit_unclear": True, "digit": "9"},
176
+ {"head_type": "countdown_damaged", "digit_unclear": False, "digit": "9"},
177
+ {
178
+ "head_type": "round",
179
+ "occlusion": True,
180
+ "occlusion_directions": ["top", "left"],
181
+ "occlusion_ratio": "medium",
182
+ },
183
+ ],
184
+ )
185
+ def test_completed_heads_satisfy_conditional_rules(attributes):
186
+ clip(body(), [head(**attributes)])
187
+
188
+
189
+ @pytest.mark.parametrize(
190
+ ("attributes", "code", "attribute"),
191
+ [
192
+ ({}, "attribute_required_on_completion", "head_type"),
193
+ ({"head_type": "arrow"}, "attribute_required_on_completion", "arrow_direction"),
194
+ ({"head_type": "round", "arrow_direction": "up"}, "attribute_not_applicable", "arrow_direction"),
195
+ ({"head_type": "countdown", "digit_unclear": False}, "attribute_required_on_completion", "digit"),
196
+ ({"head_type": "countdown_damaged", "digit_unclear": False}, "attribute_required_on_completion", "digit"),
197
+ ({"head_type": "round", "digit": "3"}, "attribute_not_applicable", "digit"),
198
+ ({"head_type": "round", "occlusion": True, "occlusion_ratio": "low"}, "attribute_required_on_completion", "occlusion_directions"),
199
+ ({"head_type": "round", "occlusion_directions": ["top"]}, "attribute_not_applicable", "occlusion_directions"),
200
+ ],
201
+ )
202
+ def test_completed_heads_reject_missing_or_leftover_facts(attributes, code, attribute):
203
+ with pytest.raises(ImageContractError) as error:
204
+ clip(body(), [head(**attributes)])
205
+ assert error.value.code == code
206
+ assert error.value.path == f"image_annotation.objects.head_a.attributes.{attribute}"
207
+
208
+
209
+ def test_drafts_may_pause_with_missing_or_leftover_facts_but_values_stay_typed():
210
+ clip(body(), [head(head_type="round", arrow_direction="up")], objects_state="unprocessed")
211
+ clip(body(), [box("head_a", {})], objects_state="unprocessed")
212
+ with pytest.raises(ImageContractError):
213
+ clip(body(), [head(head_type="round", arrow_direction="sideways")], objects_state="unprocessed")
214
+ with pytest.raises(ImageContractError): # enum_set bounds apply to drafts too
215
+ clip(
216
+ body(),
217
+ [head(head_type="round", occlusion=True, occlusion_directions=[])],
218
+ objects_state="unprocessed",
219
+ )
220
+
221
+
222
+ def test_rules_change_the_schema_fingerprint():
223
+ plain = parse_image_schema_body(body(signal_category(rules=SIGNAL_RULES[:1])))
224
+ full = parse_image_schema_body(body())
225
+ assert plain.identity.fingerprint != full.identity.fingerprint
226
+
227
+
228
+ def _rule(**change):
229
+ rule = copy.deepcopy(SIGNAL_RULES[1])
230
+ rule.update(change)
231
+ return rule
232
+
233
+
234
+ @pytest.mark.parametrize(
235
+ "rules",
236
+ [
237
+ [],
238
+ [_rule(attributes=["missing"], required=[])],
239
+ [_rule(required=["head_type"])],
240
+ [_rule(attributes=["arrow_direction", "arrow_direction"])],
241
+ [_rule(when={"attribute": "missing", "equals": "arrow"})],
242
+ [_rule(when={"attribute": "head_type", "equals": "triangle"})],
243
+ [_rule(when={"attribute": "head_type", "equals": True})],
244
+ [_rule(when={"attribute": "occlusion", "equals": "true"})],
245
+ [_rule(when={"attribute": "head_type", "in": []})],
246
+ [_rule(when={"attribute": "head_type", "in": ["arrow", "arrow"]})],
247
+ [_rule(when={"attribute": "head_type", "contains": "arrow"})],
248
+ [_rule(when={"attribute": "occlusion_directions", "equals": "top"})],
249
+ [_rule(when={"attribute": "head_type", "equals": "arrow", "in": ["arrow"]})],
250
+ [_rule(when={"all": [{"any": [{"attribute": "head_type", "equals": "arrow"}]}]})],
251
+ [_rule(when={"all": []})],
252
+ [_rule(when="head_type == 'arrow'")],
253
+ [_rule(), _rule()],
254
+ [
255
+ _rule(id="a", when={"attribute": "digit", "equals": "1"}, attributes=["arrow_direction"]),
256
+ _rule(id="b", when={"attribute": "arrow_direction", "equals": "up"}, attributes=["digit"], required=[]),
257
+ ],
258
+ ],
259
+ )
260
+ def test_schema_rejects_open_or_inconsistent_rules(rules):
261
+ with pytest.raises(ImageContractError):
262
+ parse_image_schema_body(body(signal_category(rules=rules)))
263
+
264
+
265
+ def test_governed_attribute_cannot_also_be_always_required():
266
+ category = signal_category()
267
+ category["attributes"][1]["required"] = True
268
+ with pytest.raises(ImageContractError):
269
+ parse_image_schema_body(body(category))
270
+
271
+
272
+ def test_enum_values_may_declare_english_terms():
273
+ category = signal_category()
274
+ category["attributes"][0]["values"][0]["term"] = {"singular": "round light", "plural": "round lights"}
275
+ category["attributes"][2]["values"][0]["term"] = {"singular": "zero"}
276
+ parse_image_schema_body(body(category))
277
+ for term in ({"singular": " "}, {"plural": "lights"}, {"singular": "x", "plural": ""}, {"singular": "x", "other": "y"}):
278
+ category["attributes"][0]["values"][0]["term"] = term
279
+ with pytest.raises(ImageContractError):
280
+ parse_image_schema_body(body(category))
281
+
282
+
283
+ TEXT_FIELDS = [
284
+ {"id": "phrase", "display_name": "Phrase", "required": True, "target": "object"},
285
+ {"id": "caption", "display_name": "Caption", "required": True, "target": "image"},
286
+ ]
287
+
288
+
289
+ def text(text_id, target, field, value="text"):
290
+ return {
291
+ "id": text_id,
292
+ "image_id": "image_a",
293
+ "view_id": "semantics",
294
+ "target_id": target,
295
+ "fields": {field: value},
296
+ }
297
+
298
+
299
+ def test_completed_description_scope_covers_every_object_and_the_image():
300
+ schema = body(description_fields=TEXT_FIELDS)
301
+ objects = [head(head_type="round"), {**head(head_type="round"), "id": "head_b"}]
302
+ full = [
303
+ text("p_a", "head_a", "phrase"),
304
+ text("p_b", "head_b", "phrase"),
305
+ text("caption", None, "caption"),
306
+ ]
307
+ clip(schema, objects, descriptions=full, text_state="complete")
308
+ clip(schema, objects, descriptions=full[:1], text_state="unprocessed")
309
+ for missing in range(3):
310
+ partial = full[:missing] + full[missing + 1 :]
311
+ with pytest.raises(ImageContractError) as error:
312
+ clip(schema, objects, descriptions=partial, text_state="complete")
313
+ assert error.value.code == "description_coverage_incomplete"
314
+
315
+
316
+ def test_description_targets_are_unique_and_fields_match_their_target():
317
+ schema = body(description_fields=TEXT_FIELDS)
318
+ objects = [head(head_type="round")]
319
+ with pytest.raises(ImageContractError): # two phrases for one object
320
+ clip(schema, objects, descriptions=[text("p1", "head_a", "phrase"), text("p2", "head_a", "phrase")])
321
+ with pytest.raises(ImageContractError): # caption field on an object description
322
+ clip(schema, objects, descriptions=[text("p1", "head_a", "caption")])
323
+ with pytest.raises(ImageContractError): # phrase on the image-level description
324
+ clip(schema, objects, descriptions=[text("c", None, "phrase")])
325
+ with pytest.raises(ImageContractError):
326
+ parse_image_schema_body(
327
+ body(description_fields=[{**TEXT_FIELDS[0], "target": "region"}])
328
+ )
@@ -0,0 +1,178 @@
1
+ """Controlled multi-select (`enum_set`) image attributes (x4d-devkit #223)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+
7
+ import pytest
8
+
9
+ from x4d_devkit._image_contract import ImageContractError, fingerprint
10
+ from x4d_devkit.image_clip import build_image_identity, parse_image_clip
11
+ from x4d_devkit.image_schema import parse_image_schema_body
12
+ from x4d_devkit.image_schema_upgrade import additive_image_schema_changes
13
+ from tests.image_fixtures import clip_payload, schema_body
14
+
15
+ DIRECTIONS = [
16
+ {"id": "top", "display_name": "上"},
17
+ {"id": "bottom", "display_name": "下"},
18
+ {"id": "left", "display_name": "左"},
19
+ {"id": "right", "display_name": "右"},
20
+ ]
21
+
22
+
23
+ def directions_attribute(**overrides):
24
+ attribute = {
25
+ "id": "occlusion_directions",
26
+ "display_name": "遮挡方向",
27
+ "type": "enum_set",
28
+ "required": False,
29
+ "scopes": ["object"],
30
+ "values": copy.deepcopy(DIRECTIONS),
31
+ "min_items": 0,
32
+ "max_items": 4,
33
+ }
34
+ attribute.update(overrides)
35
+ return attribute
36
+
37
+
38
+ def body_with(attribute):
39
+ body = schema_body()
40
+ body["categories"][1]["attributes"] = [attribute]
41
+ return body
42
+
43
+
44
+ def box_object(attributes):
45
+ return {
46
+ "id": "box_a",
47
+ "image_id": "image_a",
48
+ "view_id": "objects",
49
+ "category_id": "box",
50
+ "geometry": {
51
+ "geometry_type": "box_2d",
52
+ "box": {"x_min": 10, "y_min": 20, "x_max": 30, "y_max": 40},
53
+ },
54
+ "attributes": attributes,
55
+ "attribute_ranges": [],
56
+ }
57
+
58
+
59
+ def clip_with(attribute, attributes):
60
+ value = clip_payload()
61
+ body = body_with(attribute)
62
+ value["label_schema"]["body"] = body
63
+ value["label_schema"]["identity"]["fingerprint"] = fingerprint(body)
64
+ value["image_annotation"]["objects"] = [box_object(attributes)]
65
+ value["identity"] = build_image_identity(value)
66
+ return value
67
+
68
+
69
+ def test_schema_accepts_bounded_enum_set():
70
+ parsed = parse_image_schema_body(body_with(directions_attribute()))
71
+ attribute = parsed.to_dict()["categories"][1]["attributes"][0]
72
+ assert (attribute["type"], attribute["min_items"], attribute["max_items"]) == ("enum_set", 0, 4)
73
+
74
+
75
+ @pytest.mark.parametrize(
76
+ "change",
77
+ [
78
+ {"values": []},
79
+ {"values": [DIRECTIONS[0], DIRECTIONS[0]]},
80
+ {"min_items": 2, "max_items": 1},
81
+ {"max_items": 5},
82
+ {"max_items": 0},
83
+ {"min_items": -1},
84
+ {"min_items": True},
85
+ {"max_items": 2.0},
86
+ {"scopes": ["object", "segment"]},
87
+ "missing-bounds",
88
+ "bounds-on-enum",
89
+ ],
90
+ )
91
+ def test_schema_rejects_invalid_enum_set_declarations(change):
92
+ if change == "missing-bounds":
93
+ attribute = directions_attribute()
94
+ del attribute["max_items"]
95
+ elif change == "bounds-on-enum":
96
+ attribute = directions_attribute(type="enum")
97
+ else:
98
+ attribute = directions_attribute(**change)
99
+ body = body_with(attribute)
100
+ if change == {"scopes": ["object", "segment"]}:
101
+ body["categories"][0]["attributes"].append(attribute)
102
+ body["categories"][1]["attributes"] = []
103
+ with pytest.raises(ImageContractError):
104
+ parse_image_schema_body(body)
105
+
106
+
107
+ @pytest.mark.parametrize(
108
+ "value",
109
+ [[], ["top"], ["top", "left"], ["top", "bottom", "left", "right"]],
110
+ )
111
+ def test_clip_accepts_declared_order_sets_and_keeps_them_exactly(value):
112
+ clip = parse_image_clip(clip_with(directions_attribute(), {"occlusion_directions": value}))
113
+ stored = clip.to_dict()["image_annotation"]["objects"][0]["attributes"]
114
+ assert stored == {"occlusion_directions": value}
115
+
116
+
117
+ def test_missing_and_empty_set_are_distinct_results():
118
+ missing = parse_image_clip(clip_with(directions_attribute(), {}))
119
+ empty = parse_image_clip(clip_with(directions_attribute(), {"occlusion_directions": []}))
120
+ assert (
121
+ missing.to_dict()["identity"]["annotation_revision"]
122
+ != empty.to_dict()["identity"]["annotation_revision"]
123
+ )
124
+
125
+
126
+ @pytest.mark.parametrize(
127
+ "value",
128
+ [
129
+ ["left", "top"], # not Schema declaration order: never re-sorted silently
130
+ ["top", "top"],
131
+ ["up"],
132
+ "top",
133
+ "top,left",
134
+ [1],
135
+ [None],
136
+ [["top"]],
137
+ {"top": True},
138
+ None,
139
+ True,
140
+ ],
141
+ )
142
+ def test_clip_rejects_invalid_set_values_without_coercion(value):
143
+ with pytest.raises(ImageContractError):
144
+ parse_image_clip(clip_with(directions_attribute(), {"occlusion_directions": value}))
145
+
146
+
147
+ def test_clip_enforces_item_bounds_and_required_presence():
148
+ bounded = directions_attribute(min_items=1, max_items=2)
149
+ with pytest.raises(ImageContractError):
150
+ parse_image_clip(clip_with(bounded, {"occlusion_directions": []}))
151
+ with pytest.raises(ImageContractError):
152
+ parse_image_clip(clip_with(bounded, {"occlusion_directions": ["top", "bottom", "left"]}))
153
+ parse_image_clip(clip_with(bounded, {"occlusion_directions": ["bottom", "right"]}))
154
+
155
+ required = directions_attribute(required=True)
156
+ with pytest.raises(ImageContractError):
157
+ parse_image_clip(clip_with(required, {}))
158
+ parse_image_clip(clip_with(required, {"occlusion_directions": []}))
159
+
160
+
161
+ def test_schema_upgrade_may_add_values_but_must_keep_existing_order():
162
+ before = body_with(directions_attribute())
163
+ after = copy.deepcopy(before)
164
+ after["version"] = "1.1.0"
165
+ after["categories"][1]["attributes"][0]["values"].insert(
166
+ 2, {"id": "front", "display_name": "前"}
167
+ )
168
+ changes = additive_image_schema_changes(before, after)
169
+ assert {"category_id": "box", "attribute_id": "occlusion_directions", "value_id": "front"} in changes[
170
+ "added_enum_values"
171
+ ]
172
+
173
+ reordered = copy.deepcopy(before)
174
+ reordered["version"] = "1.1.0"
175
+ values = reordered["categories"][1]["attributes"][0]["values"]
176
+ values[0], values[1] = values[1], values[0]
177
+ with pytest.raises(ImageContractError):
178
+ additive_image_schema_changes(before, reordered)
@@ -836,9 +836,10 @@ def test_annotation_entry_points_canonicalize_rows_only_once(monkeypatch, entry)
836
836
  assert annotation_revision(raw["clip_id"], raw["annotation_frame_id"],
837
837
  raw["annotations"]) == raw["annotation_revision"]
838
838
  # Revision and document hash share one serialization of every row; no call
839
- # re-serializes the row list or a complete payload.
839
+ # re-serializes the row list or a complete payload. A document this module
840
+ # already verified is returned without serializing anything.
840
841
  row_calls = [value for value in calls if isinstance(value, dict)]
841
- assert row_calls == raw["annotations"]
842
+ assert row_calls == ([] if entry == "parsed-document" else raw["annotations"])
842
843
  assert all(isinstance(value, str) for value in calls if not isinstance(value, dict))
843
844
 
844
845
 
@@ -11,7 +11,7 @@ from x4d_devkit.eval.matching import MetricData
11
11
 
12
12
 
13
13
  def test_package_version_is_current_release():
14
- assert x4d_devkit.__version__ == "0.51.0rc3"
14
+ assert x4d_devkit.__version__ == "0.52.0"
15
15
 
16
16
 
17
17
  def test_loader_exposes_and_enforces_only_current_schema(tmp_path):
@@ -38,6 +38,13 @@ _DOCUMENT_FIELDS = {
38
38
  "annotations",
39
39
  }
40
40
  _REVISION_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
41
+ # Set only by build/parse after canonical verification; never by document_hash().
42
+ _VERIFIED_MARK = "_canonical_verified"
43
+
44
+
45
+ def _mark_verified(document: "ClipAnnotationDocument", document_hash: str) -> None:
46
+ object.__setattr__(document, "_document_hash", document_hash)
47
+ object.__setattr__(document, _VERIFIED_MARK, True)
41
48
  _FRAME_ID_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
42
49
 
43
50
 
@@ -148,7 +155,7 @@ def build_clip_annotations(
148
155
  annotation_revision=revision,
149
156
  annotations=tuple(canonical),
150
157
  )
151
- object.__setattr__(document, "_document_hash", document_hash)
158
+ _mark_verified(document, document_hash)
152
159
  return document
153
160
 
154
161
 
@@ -159,6 +166,9 @@ def parse_clip_annotations(
159
166
 
160
167
  i_json_checked = False
161
168
  if isinstance(value, ClipAnnotationDocument):
169
+ if value.__dict__.get(_VERIFIED_MARK) is True:
170
+ # Built or parsed by this module: already canonical and verified.
171
+ return value
162
172
  data = value.to_dict()
163
173
  elif isinstance(value, Mapping):
164
174
  data = dict(value)
@@ -244,7 +254,7 @@ def parse_clip_annotations(
244
254
  annotation_revision=revision,
245
255
  annotations=tuple(annotations),
246
256
  )
247
- object.__setattr__(document, "_document_hash", document_hash)
257
+ _mark_verified(document, document_hash)
248
258
  return document
249
259
 
250
260