assertpy2 2.28.0__tar.gz → 2.29.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 (264) hide show
  1. {assertpy2-2.28.0 → assertpy2-2.29.0}/PKG-INFO +1 -1
  2. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_compare.py +1 -1
  3. assertpy2-2.29.0/assertpy2/_engine/_contract.py +579 -0
  4. assertpy2-2.29.0/assertpy2/_engine/_diff.py +916 -0
  5. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_equality.py +86 -18
  6. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_membership.py +2 -0
  7. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_ordering.py +34 -4
  8. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_path.py +70 -12
  9. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_matcher_impls.py +54 -31
  10. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_satisfies.py +36 -26
  11. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/assertpy.py +23 -14
  12. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/base.py +12 -7
  13. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/collection.py +21 -6
  14. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/contains.py +34 -21
  15. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/date.py +5 -5
  16. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/dynamic.py +3 -1
  17. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/errors.py +13 -0
  18. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/helpers.py +34 -31
  19. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/numeric.py +6 -6
  20. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/snapshot.py +3 -3
  21. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/string.py +21 -12
  22. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/concepts/type-safety.md +21 -1
  23. {assertpy2-2.28.0 → assertpy2-2.29.0}/pyproject.toml +1 -1
  24. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_complexity.py +4 -10
  25. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_datetime.py +25 -24
  26. assertpy2-2.29.0/tests/test_deep_nesting.py +437 -0
  27. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_duality.py +13 -156
  28. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_evaluation_core.py +486 -2
  29. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_match_result.py +53 -0
  30. assertpy2-2.29.0/tests/test_matcher_algebra.py +230 -0
  31. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_property_based.py +9 -4
  32. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_compare.py +17 -0
  33. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_structural.py +831 -38
  34. assertpy2-2.29.0/tests/test_unrenderable_values.py +244 -0
  35. assertpy2-2.28.0/assertpy2/_engine/_contract.py +0 -166
  36. assertpy2-2.28.0/assertpy2/_engine/_diff.py +0 -718
  37. {assertpy2-2.28.0 → assertpy2-2.29.0}/.gitignore +0 -0
  38. {assertpy2-2.28.0 → assertpy2-2.29.0}/CONTRIBUTING.md +0 -0
  39. {assertpy2-2.28.0 → assertpy2-2.29.0}/LICENSE +0 -0
  40. {assertpy2-2.28.0 → assertpy2-2.29.0}/README.md +0 -0
  41. {assertpy2-2.28.0 → assertpy2-2.29.0}/SECURITY.md +0 -0
  42. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/__init__.py +0 -0
  43. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_clustering.py +0 -0
  44. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_dangling.py +0 -0
  45. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/__init__.py +0 -0
  46. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_builder_check_typing.py +0 -0
  47. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_capable_typing.py +0 -0
  48. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_check_typing.py +0 -0
  49. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_compat.py +0 -0
  50. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_introspection.py +0 -0
  51. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_mixin_base.py +0 -0
  52. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_negated_typing.py +0 -0
  53. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_operations.py +0 -0
  54. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_pairing.py +0 -0
  55. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_poll_typing.py +0 -0
  56. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_require.py +0 -0
  57. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_size.py +0 -0
  58. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_text.py +0 -0
  59. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_typing.py +0 -0
  60. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_hints.py +0 -0
  61. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_inline.py +0 -0
  62. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_snapshot_codec.py +0 -0
  63. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/async_assertions.py +0 -0
  64. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/behave_matchers.py +0 -0
  65. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/bytes_mixin.py +0 -0
  66. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/dataframe.py +0 -0
  67. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/dict.py +0 -0
  68. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/exception.py +0 -0
  69. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/extracting.py +0 -0
  70. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/file.py +0 -0
  71. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/http_mixin.py +0 -0
  72. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/json_mixin.py +0 -0
  73. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/matchers.py +0 -0
  74. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/outcome.py +0 -0
  75. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/py.typed +0 -0
  76. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/pytest_plugin.py +0 -0
  77. {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/warning.py +0 -0
  78. {assertpy2-2.28.0 → assertpy2-2.29.0}/benchmarks/test_perf.py +0 -0
  79. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-equal.png +0 -0
  80. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-equal.svg +0 -0
  81. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-gallery.png +0 -0
  82. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-match.svg +0 -0
  83. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-sequence.svg +0 -0
  84. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-set.svg +0 -0
  85. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-string.svg +0 -0
  86. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/concepts/stability.md +0 -0
  87. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/extending/custom-assertions.md +0 -0
  88. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/extending/integrations.md +0 -0
  89. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/getting-started/comparison.md +0 -0
  90. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/getting-started/migration.md +0 -0
  91. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/getting-started/quickstart.md +0 -0
  92. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/assertions.md +0 -0
  93. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/data.md +0 -0
  94. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/errors.md +0 -0
  95. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/fluent.md +0 -0
  96. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/matchers.md +0 -0
  97. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/testing.md +0 -0
  98. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/index.md +0 -0
  99. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/logo-dark.svg +0 -0
  100. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/logo.svg +0 -0
  101. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/recipes.md +0 -0
  102. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/async.md +0 -0
  103. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/bytes.md +0 -0
  104. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/collections.md +0 -0
  105. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/containment.md +0 -0
  106. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/core.md +0 -0
  107. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/dataframes.md +0 -0
  108. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/dates.md +0 -0
  109. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/dicts.md +0 -0
  110. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/dynamic.md +0 -0
  111. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/entry-points.md +0 -0
  112. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/errors.md +0 -0
  113. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/exceptions.md +0 -0
  114. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/extracting.md +0 -0
  115. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/files.md +0 -0
  116. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/http.md +0 -0
  117. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/json.md +0 -0
  118. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/matchers.md +0 -0
  119. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/numbers.md +0 -0
  120. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/overview.md +0 -0
  121. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/snapshots.md +0 -0
  122. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/strings.md +0 -0
  123. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/warnings.md +0 -0
  124. {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/stylesheets/extra.css +0 -0
  125. {assertpy2-2.28.0 → assertpy2-2.29.0}/mkdocs.yml +0 -0
  126. {assertpy2-2.28.0 → assertpy2-2.29.0}/mkdocs_hooks.py +0 -0
  127. {assertpy2-2.28.0 → assertpy2-2.29.0}/scripts/generate_check_protocols.py +0 -0
  128. {assertpy2-2.28.0 → assertpy2-2.29.0}/scripts/generate_poll_protocols.py +0 -0
  129. {assertpy2-2.28.0 → assertpy2-2.29.0}/scripts/mutation_report.py +0 -0
  130. {assertpy2-2.28.0 → assertpy2-2.29.0}/scripts/regenerate.py +0 -0
  131. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/api_snapshot.json +0 -0
  132. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/api_surface.py +0 -0
  133. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/chain_corpus.py +0 -0
  134. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/chain_model.py +0 -0
  135. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/chain_surfaces.py +0 -0
  136. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/conftest.py +0 -0
  137. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/docs_fixtures.py +0 -0
  138. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/group_compat.py +0 -0
  139. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/poll_parity_baseline.py +0 -0
  140. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/poll_parity_cases.py +0 -0
  141. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/pyright_baseline.py +0 -0
  142. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_api_compatibility.py +0 -0
  143. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_api_vocabulary.py +0 -0
  144. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_architecture_doc.py +0 -0
  145. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_async.py +0 -0
  146. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_async_predicates.py +0 -0
  147. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_attrs_support.py +0 -0
  148. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_behave_matchers.py +0 -0
  149. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_bool.py +0 -0
  150. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_boundary_cases.py +0 -0
  151. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_bytes.py +0 -0
  152. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_call_awaitables.py +0 -0
  153. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_callable.py +0 -0
  154. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_capable_protocol.py +0 -0
  155. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_chain_machine.py +0 -0
  156. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_chaining.py +0 -0
  157. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_check.py +0 -0
  158. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_check_protocols.py +0 -0
  159. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_class.py +0 -0
  160. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_clustering.py +0 -0
  161. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_collection.py +0 -0
  162. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_core.py +0 -0
  163. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_custom_dict.py +0 -0
  164. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_custom_list.py +0 -0
  165. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dangling.py +0 -0
  166. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dataframe.py +0 -0
  167. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_declared_floors.py +0 -0
  168. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_description.py +0 -0
  169. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dict.py +0 -0
  170. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dict_compare.py +0 -0
  171. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_docs_examples.py +0 -0
  172. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_docs_typing.py +0 -0
  173. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dynamic.py +0 -0
  174. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_equality_signals.py +0 -0
  175. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_equals.py +0 -0
  176. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_errors.py +0 -0
  177. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_exception_context.py +0 -0
  178. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_expected_contract.py +0 -0
  179. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_expected_exception.py +0 -0
  180. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_expected_warning.py +0 -0
  181. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_extensions.py +0 -0
  182. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_extracting.py +0 -0
  183. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_fail.py +0 -0
  184. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_file.py +0 -0
  185. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_grouped_soft.py +0 -0
  186. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_hints.py +0 -0
  187. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_hostile_values.py +0 -0
  188. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_http.py +0 -0
  189. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_in.py +0 -0
  190. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_inline_record.py +0 -0
  191. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_inline_snapshot.py +0 -0
  192. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_integer_arguments.py +0 -0
  193. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_iterable_cluster.py +0 -0
  194. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_json.py +0 -0
  195. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_list.py +0 -0
  196. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_matcher_parity.py +0 -0
  197. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_matcher_registry.py +0 -0
  198. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_matchers.py +0 -0
  199. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_message_elision.py +0 -0
  200. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_namedtuple.py +0 -0
  201. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_negated_protocols.py +0 -0
  202. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_none.py +0 -0
  203. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_not.py +0 -0
  204. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_number_zoo.py +0 -0
  205. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_numbers.py +0 -0
  206. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_numeric_questions.py +0 -0
  207. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_openapi_contract.py +0 -0
  208. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_operation_contract.py +0 -0
  209. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_optional_integration_contracts.py +0 -0
  210. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_overload_order.py +0 -0
  211. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_overloads.py +0 -0
  212. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_pin_coverage.py +0 -0
  213. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_pipeline.py +0 -0
  214. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_poll_parity.py +0 -0
  215. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_poll_protocols.py +0 -0
  216. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_prerequisites.py +0 -0
  217. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_property_attrs.py +0 -0
  218. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_protocol_parity.py +0 -0
  219. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_public_surface.py +0 -0
  220. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_pyright_baseline.py +0 -0
  221. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_pytest_plugin.py +0 -0
  222. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_assertion.py +0 -0
  223. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_compare_attrs.py +0 -0
  224. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_compare_config.py +0 -0
  225. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_compare_pydantic.py +0 -0
  226. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_refusals.py +0 -0
  227. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_regex_groups.py +0 -0
  228. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_requirement.py +0 -0
  229. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_rich_diff.py +0 -0
  230. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_same_as.py +0 -0
  231. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_satisfy.py +0 -0
  232. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_skip_guard.py +0 -0
  233. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_snapshots.py +0 -0
  234. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_soft.py +0 -0
  235. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_soft_fail.py +0 -0
  236. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_string.py +0 -0
  237. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_surface_conformance.py +0 -0
  238. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_sync_eventually.py +0 -0
  239. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_traceback.py +0 -0
  240. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_type.py +0 -0
  241. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_type_expression_failures.py +0 -0
  242. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing.py +0 -0
  243. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_chain_corpus.py +0 -0
  244. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_claims.py +0 -0
  245. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_completeness.py +0 -0
  246. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_conformance.py +0 -0
  247. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_from_a_wheel.py +0 -0
  248. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_http.py +0 -0
  249. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_integrations.py +0 -0
  250. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_negative.py +0 -0
  251. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_promises.py +0 -0
  252. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_vacuity_contract.py +0 -0
  253. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_verdict_corpus.py +0 -0
  254. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_warn.py +0 -0
  255. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_cases.py +0 -0
  256. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_chain_corpus_baseline.json +0 -0
  257. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_harness.py +0 -0
  258. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_http.py +0 -0
  259. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_integrations.py +0 -0
  260. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_integrations_baseline.py +0 -0
  261. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_negative_baseline.py +0 -0
  262. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_promises.py +0 -0
  263. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/verdict_corpus.py +0 -0
  264. {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/verdict_golden.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: assertpy2
3
- Version: 2.28.0
3
+ Version: 2.29.0
4
4
  Summary: Fluent, fully typed assertion library for Python: your type checker sees only the assertions that fit the value, and every failure carries a structured diff. Soft assertions, polling, inline snapshots, OpenAPI contracts.
5
5
  Project-URL: Homepage, https://github.com/Solganis/assertpy2
6
6
  Project-URL: Repository, https://github.com/Solganis/assertpy2
@@ -630,7 +630,7 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
630
630
  ``"strict"`` is the fourth: the two sides are equal, but ``strict_types`` or a comparator still has to
631
631
  look inside, because a container's ``==`` says nothing about the types of its members, nor about what a
632
632
  comparator says of them. It differs from ``"recurse"`` only in what an undecomposable value means,
633
- which `assertpy2._engine._diff._child_entries()` is the single place to know.
633
+ which `assertpy2._engine._diff._Walk.descend()` is the single place to know.
634
634
  """
635
635
  if config is not None:
636
636
  if config.ignore_null and field is not None and as_held(expected) is None:
@@ -0,0 +1,579 @@
1
+ """Contract-drift detection for `assert_conforms(..., exact=True)`.
2
+
3
+ Reports fields a raw payload carries that its pydantic v2 model does **not** declare - the silent API
4
+ growth that `model_validate` drops by default. Duck-typed on ``model_fields`` (no pydantic import),
5
+ alias-aware, and walked beside the validated instance, so every nested model is checked where pydantic put one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import collections.abc
11
+ import datetime
12
+ import decimal
13
+ import enum
14
+ import fractions
15
+ import json
16
+ import pathlib
17
+ import types
18
+ import uuid
19
+ from functools import reduce
20
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, Union, get_args, get_origin
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable
24
+
25
+
26
+ def _alias_sources(alias: object) -> list[tuple[object, ...]]:
27
+ """The payload paths an alias reads, in the order pydantic tries them (str, ``AliasChoices``, ``AliasPath``)."""
28
+ if isinstance(alias, str):
29
+ return [(alias,)]
30
+ choices = getattr(alias, "choices", None)
31
+ if choices is not None:
32
+ return [source for choice in choices for source in _alias_sources(choice)]
33
+ path = getattr(alias, "path", None)
34
+ return [tuple(path)] if path else []
35
+
36
+
37
+ def _declared_keys(model: Any) -> set[str]:
38
+ """Field names plus the top-level key of every alias, so an aliased payload key is not mistaken for drift."""
39
+ keys: set[str] = set()
40
+ for name, info in model.model_fields.items():
41
+ keys.add(name)
42
+ for alias in (getattr(info, "alias", None), getattr(info, "validation_alias", None)):
43
+ if alias is not None:
44
+ keys.update(source[0] for source in _alias_sources(alias) if isinstance(source[0], str))
45
+ return keys
46
+
47
+
48
+ def _field_sources(name: str, info: Any, config: Any) -> tuple[tuple[object, ...], ...]:
49
+ """Where pydantic reads field *name*, in the order it looks: its alias sources, then the name if allowed."""
50
+ validation_alias = getattr(info, "validation_alias", None)
51
+ alias = validation_alias if validation_alias is not None else getattr(info, "alias", None)
52
+ if alias is None:
53
+ return ((name,),)
54
+ sources = _alias_sources(alias) if config.get("validate_by_alias") is not False else []
55
+ by_name = config.get("validate_by_name")
56
+ if by_name if by_name is not None else config.get("populate_by_name"):
57
+ sources.append((name,))
58
+ return tuple(sources)
59
+
60
+
61
+ _KEYLESS = (
62
+ int,
63
+ float,
64
+ complex,
65
+ str,
66
+ bytes,
67
+ bytearray,
68
+ type(None),
69
+ decimal.Decimal,
70
+ fractions.Fraction,
71
+ datetime.date,
72
+ datetime.time,
73
+ datetime.timedelta,
74
+ uuid.UUID,
75
+ enum.Enum,
76
+ pathlib.PurePath,
77
+ )
78
+ _PLAIN_ORIGINS = {
79
+ list,
80
+ tuple,
81
+ set,
82
+ frozenset,
83
+ dict,
84
+ collections.abc.Sequence,
85
+ collections.abc.MutableSequence,
86
+ collections.abc.Set,
87
+ collections.abc.MutableSet,
88
+ collections.abc.Mapping,
89
+ collections.abc.MutableMapping,
90
+ collections.abc.Iterable,
91
+ collections.abc.Iterator,
92
+ collections.abc.Collection,
93
+ }
94
+
95
+
96
+ def _may_hold_model(annotation: object, outer: bool = True) -> bool:
97
+ """Whether a model can be built anywhere under a field's *annotation*; only a type made of plain parts says no.
98
+
99
+ ``Any`` says yes at the top of the field, where only a validator puts a model and the payload's own object kept
100
+ there costs one identity check, and no inside a container, where passing each kept item costs what skipping saves.
101
+ """
102
+ if annotation is Any or annotation is object:
103
+ return outer
104
+ if annotation in (list, tuple, set, frozenset, dict):
105
+ return False
106
+ origin = get_origin(annotation)
107
+ if origin is Literal:
108
+ return False
109
+ if origin is Annotated:
110
+ return _may_hold_model(get_args(annotation)[0], outer)
111
+ if origin is Union or origin is types.UnionType:
112
+ return any(_may_hold_model(arg, outer) for arg in get_args(annotation))
113
+ if origin in _PLAIN_ORIGINS:
114
+ return any(_may_hold_model(arg, outer=False) for arg in get_args(annotation) if arg is not Ellipsis)
115
+ return origin is not None or not isinstance(annotation, type) or not issubclass(annotation, _KEYLESS)
116
+
117
+
118
+ _FieldReads = tuple[tuple[str, tuple[tuple[object, ...], ...]], ...]
119
+
120
+ _READS: dict[type, tuple[object, frozenset[str], _FieldReads]] = {}
121
+ """Per model class, with the validator they were read beside: the keys it declares and where each field is read.
122
+
123
+ Worked out per level of every payload, it was most of the walk: a list of 1000 nested models asked for it 1000 times.
124
+ A field whose type cannot hold a model is left out: a 300 by 300 grid of floats cost 35 ms to walk and holds nothing.
125
+ A rebuild replaces the validator, and with it what the class reads, so an entry answers only for the validator it
126
+ was made with. At most 256 classes are kept, so models made on the fly cannot grow it without bound."""
127
+
128
+
129
+ def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads]:
130
+ validator = getattr(model, "__pydantic_validator__", None)
131
+ known = _READS.get(model)
132
+ if known is None or known[0] is not validator:
133
+ config = getattr(model, "model_config", {})
134
+ fields = tuple(
135
+ (name, _field_sources(name, info, config))
136
+ for name, info in model.model_fields.items()
137
+ if _may_hold_model(getattr(info, "annotation", None))
138
+ )
139
+ known = (validator, frozenset(_declared_keys(model)), fields)
140
+ if model in _READS or len(_READS) < 256:
141
+ _READS[model] = known
142
+ return known[1], known[2]
143
+
144
+
145
+ def _followed(payload: object, path: tuple[object, ...]) -> tuple[bool, object]:
146
+ """What *path* reaches in *payload*, key by key and index by index, and whether it reached anything."""
147
+ current: Any = payload
148
+ for step in path:
149
+ if isinstance(current, dict):
150
+ if step not in current:
151
+ return False, None
152
+ current = current[step]
153
+ elif not isinstance(current, str):
154
+ # read as pydantic reads it, through whatever `__getitem__` the object has
155
+ try:
156
+ current = current[step]
157
+ except (LookupError, TypeError):
158
+ return False, None
159
+ else:
160
+ return False, None
161
+ return True, current
162
+
163
+
164
+ def _raw_field(payload: collections.abc.Mapping, sources: tuple[tuple[object, ...], ...]) -> tuple[bool, object]:
165
+ """The part of *payload* pydantic validated a field from: the first of its sources that is there."""
166
+ for source in sources:
167
+ found, value = _followed(payload, source)
168
+ if found:
169
+ return True, value
170
+ return False, None
171
+
172
+
173
+ def _is_model(value: object) -> bool:
174
+ return not isinstance(value, type) and hasattr(type(value), "model_fields")
175
+
176
+
177
+ _CONTAINERS = (dict, list, tuple, set, frozenset)
178
+
179
+
180
+ class UncheckableDriftError(Exception):
181
+ """A part of the payload no reading pairs with the model it became, so whether it drifted cannot be told."""
182
+
183
+ def __init__(self, path: str, reason: str) -> None:
184
+ super().__init__(f"{path or 'the payload'}: {reason}")
185
+ self.path = path
186
+ self.reason = reason
187
+
188
+
189
+ def contract_drift(
190
+ payload: object, instance: Any, path: str = "", _seen: frozenset[tuple[int, int]] = frozenset()
191
+ ) -> list[str]:
192
+ """Paths of fields ``payload`` carries that the model ``instance`` was validated into does not declare.
193
+
194
+ Walked beside the instance rather than read off the annotations: which member of a union, which element type
195
+ of a tuple and which dict value a part of the payload became is what pydantic decided, and the instance holds
196
+ the answer. A model whose config opts into extras (``extra="allow"``) keeps them intentionally, so its own
197
+ level is skipped.
198
+
199
+ Raises:
200
+ UncheckableDriftError: where a part of the payload that could hide an undeclared key (a dict inside it, or
201
+ inside the JSON text it is) cannot be paired with a model built from it: a set, a resized list or an
202
+ object wrapped into either whose built items are not all one model class, or whose raw item that class
203
+ refuses on its own, dict keys coercion merged, a part of another shape than what was built, an iterable
204
+ validation read up or left lazy. The payload is read as validation left it: a validator renaming keys,
205
+ changing the payload in place, or reordering or rewriting a container's items without changing its size
206
+ is not seen, and the walk reads those items by position.
207
+ """
208
+ model = type(instance)
209
+ if getattr(model, "__pydantic_root_model__", False):
210
+ return _value_drift(payload, instance.root, path, _seen) if _reads_of(model)[1] else []
211
+ if not isinstance(payload, (dict, collections.abc.Mapping)):
212
+ _refuse_if_key_hides(
213
+ payload, instance, path, f"the payload holds a {type(payload).__name__} where a model was built"
214
+ )
215
+ return []
216
+ seen = _seen | {(id(payload), id(instance))}
217
+ declared, fields = _reads_of(model)
218
+ prefix = f"{path}." if path else ""
219
+ drift: list[str] = []
220
+ if getattr(model, "model_config", {}).get("extra") != "allow":
221
+ drift += [f"{prefix}{key}" for key in payload if key not in declared]
222
+ else:
223
+ # extras typed through `__pydantic_extra__` are built too; untyped ones are the payload's own objects
224
+ for key, built in (getattr(instance, "__pydantic_extra__", None) or {}).items():
225
+ if key in payload:
226
+ drift += _value_drift(payload[key], built, f"{prefix}{key}", seen)
227
+ for name, sources in fields:
228
+ found, raw = _raw_field(payload, sources)
229
+ if not found:
230
+ continue
231
+ value = getattr(instance, name, None)
232
+ # a scalar built from a scalar holds nothing to pair
233
+ if (value is None or isinstance(value, (str, int, float, bytes))) and not isinstance(raw, _CONTAINERS):
234
+ continue
235
+ drift += _value_drift(raw, value, f"{prefix}{name}", seen)
236
+ return drift
237
+
238
+
239
+ def exactness_failure(pairs: Any, *, carrier: str) -> str | None:
240
+ """What an exact check of ``(payload, instance, path)`` pairs found, in the words a failure continues with.
241
+
242
+ The undeclared fields, after *carrier*, or the first part that cannot be checked; ``None`` when neither.
243
+ """
244
+ drift: list[str] = []
245
+ try:
246
+ for payload, instance, path in pairs:
247
+ drift += contract_drift(payload, instance, path)
248
+ except UncheckableDriftError as refusal:
249
+ return f"<{refusal.path or 'the payload'}> cannot be checked: {refusal.reason}"
250
+ except RecursionError:
251
+ return "<the payload> cannot be checked: it nests deeper than the walk can follow"
252
+ if not drift:
253
+ return None
254
+ return f"{carrier}{len(drift)} undeclared field(s) the model does not declare: {sorted(drift)}"
255
+
256
+
257
+ def _value_drift(raw: object, value: object, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
258
+ """Drift under one validated value: a model's own keys, and every element or dict value that became a model."""
259
+ # the payload's own object kept as it was, or a scalar: nothing was built from it
260
+ if raw is value or value is None or isinstance(value, (str, int, float, bytes)):
261
+ return []
262
+ if isinstance(raw, (str, bytes, bytearray)):
263
+ raw = _decoded_container(raw)
264
+ # a pair already on the path is a cycle, the payload's own or one a validator built by assigning a model to itself
265
+ pair = (id(raw), id(value))
266
+ if pair in seen:
267
+ return []
268
+ seen = seen | {pair}
269
+ if _is_model(value):
270
+ return contract_drift(raw, value, path, seen)
271
+ return _container_drift(raw, value, path, seen)
272
+
273
+
274
+ def _container_drift(raw: object, value: object, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
275
+ """Drift under a built container, paired with the raw part it came from as its kind allows."""
276
+ if isinstance(value, (list, tuple)):
277
+ return _sequence_drift(raw, value, path, seen)
278
+ if isinstance(value, dict):
279
+ return _mapping_drift(raw, value, path, seen)
280
+ if isinstance(value, (set, frozenset)):
281
+ return _set_drift(raw, value, path, seen)
282
+ if isinstance(value, collections.abc.Mapping):
283
+ return _mapping_drift(raw, dict(value), path, seen)
284
+ if isinstance(value, collections.abc.Iterator):
285
+ if _holds(raw, _is_mapping, read_text=True):
286
+ raise UncheckableDriftError(path, "validation is lazy here and builds the models only as the value is read")
287
+ return []
288
+ # a sequence validation kept as the payload's own kind, a deque under `Sequence[A]`
289
+ items = _items_of(value)
290
+ return [] if items is None else _sequence_drift(raw, items, path, seen)
291
+
292
+
293
+ def _sequence_drift(raw: object, value: list | tuple, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
294
+ """Paired by index, a collection pydantic also takes for a sequence (a deque, a set, a dict's values) in the
295
+ order it iterates, which is the order validation read it in."""
296
+ if not isinstance(raw, (list, tuple)):
297
+ items = _items_of(raw)
298
+ if items is None:
299
+ reason = f"the payload holds a {type(raw).__name__} where a sequence was built"
300
+ return _wrapped(raw, value, path, seen, reason)
301
+ raw = items
302
+ if len(raw) != len(value):
303
+ reason = f"validation changed its length, {len(raw)} items became {len(value)}"
304
+ return _unpaired(raw, [(f"{path}[{i}]", part) for i, part in enumerate(raw)], value, path, seen, reason)
305
+ return [
306
+ entry
307
+ for index, (part, element) in enumerate(zip(raw, value, strict=True))
308
+ for entry in _value_drift(part, element, f"{path}[{index}]", seen)
309
+ ]
310
+
311
+
312
+ def _mapping_drift(raw: object, value: dict, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
313
+ """Paired by key where validation kept the keys (a `TypedDict` reorders them), else by order, which dict validation
314
+ keeps while it coerces them; a key built into something other than text or a number is walked beside its raw key."""
315
+ if not isinstance(raw, (dict, collections.abc.Mapping)):
316
+ _refuse_if_key_hides(raw, value, path, f"the payload holds a {type(raw).__name__} where a mapping was built")
317
+ return []
318
+ if len(raw) != len(value):
319
+ # an overwritten value may have become another member of a union than the one that survived
320
+ _refuse_if_key_hides(raw, value, path, f"validation changed its size, {len(raw)} keys became {len(value)}")
321
+ return []
322
+ if raw.keys() == value.keys():
323
+ return [
324
+ entry
325
+ for key, part in raw.items()
326
+ for entry in _value_drift(part, value[key], f"{path}.{key}" if path else str(key), seen)
327
+ ]
328
+ drift: list[str] = []
329
+ for (key, part), (built, element) in zip(raw.items(), value.items(), strict=True):
330
+ if not isinstance(built, (str, int)):
331
+ drift += _value_drift(key, built, path, seen)
332
+ drift += _value_drift(part, element, f"{path}.{key}" if path else str(key), seen)
333
+ return drift
334
+
335
+
336
+ def _set_drift(raw: object, value: set | frozenset, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
337
+ items = raw if isinstance(raw, (list, tuple)) else _items_of(raw)
338
+ if items is None:
339
+ reason = f"the payload holds a {type(raw).__name__} where a set was built"
340
+ return _wrapped(raw, value, path, seen, reason)
341
+ reason = "a set keeps no order to pair its items with the models they became"
342
+ parts = [(f"{path}[{i}]", part) for i, part in enumerate(items)]
343
+ return _unpaired(raw, parts, value, path, seen, reason, merged=True)
344
+
345
+
346
+ def _wrapped(raw: object, value: object, path: str, seen: frozenset[tuple[int, int]], reason: str) -> list[str]:
347
+ """Drift under a mapping a validator turned into a sequence or a set, a single object sent where many may be.
348
+
349
+ Only a mapping that became one item is that item; one that became several was expanded in a way no reading
350
+ pairs.
351
+ """
352
+ if isinstance(raw, (dict, collections.abc.Mapping)) and len(_items_of(value) or ()) == 1:
353
+ return _unpaired(raw, [(path, raw)], value, path, seen, reason)
354
+ _refuse_if_key_hides(raw, value, path, reason)
355
+ return []
356
+
357
+
358
+ def _unpaired(
359
+ raw: object,
360
+ parts: list[tuple[str, object]],
361
+ value: object,
362
+ path: str,
363
+ seen: frozenset[tuple[int, int]],
364
+ reason: str,
365
+ *,
366
+ merged: bool = False,
367
+ ) -> list[str]:
368
+ """Drift under a container whose raw items no reading pairs one by one with the items validation built.
369
+
370
+ Where every built item is an instance of one model class, each raw item became one too, so each raw item that
371
+ could hide a key is validated again on its own by that class, JSON text in JSON mode as `Json[...]` read it, and
372
+ walked beside what it becomes. Anything else refuses with *reason*: items of mixed classes, an item the class
373
+ refuses on its own, which needed its parent, and, where equal items *merged*, a class whose own `__eq__` could
374
+ have merged an item of another class into it.
375
+ """
376
+ if not _holds(value, _builds_model) or not _holds(raw, _is_mapping, read_text=True):
377
+ return []
378
+ model = _sole_model_class(value)
379
+ validate = getattr(model, "model_validate", None)
380
+ validate_json = getattr(model, "model_validate_json", None)
381
+ if validate is None or validate_json is None or (merged and not _equal_only_within_its_class(model)):
382
+ raise UncheckableDriftError(path, reason)
383
+ drift: list[str] = []
384
+ for label, item in parts:
385
+ part = _decoded_container(item) if isinstance(item, (str, bytes, bytearray)) else item
386
+ if _holds(part, _is_mapping, read_text=True):
387
+ try:
388
+ built = validate(part) if part is item else validate_json(item)
389
+ except ValueError:
390
+ raise UncheckableDriftError(path, reason) from None
391
+ drift += _value_drift(part, built, label, seen)
392
+ return drift
393
+
394
+
395
+ def _equal_only_within_its_class(model: type | None) -> bool:
396
+ """Whether equal instances of *model* share its class: its `__eq__` is pydantic's own, which compares classes.
397
+
398
+ A parametrized generic model does not: pydantic compares its origin, which every specialization shares.
399
+ """
400
+ if (getattr(model, "__pydantic_generic_metadata__", None) or {}).get("origin") is not None:
401
+ return False
402
+ owner = next((klass for klass in getattr(model, "__mro__", ()) if "__eq__" in vars(klass)), object)
403
+ return owner.__module__.startswith("pydantic.")
404
+
405
+
406
+ def _sole_model_class(value: object) -> type | None:
407
+ """The one model class every item of a built container is an instance of, or ``None``."""
408
+ items = _items_of(value) or []
409
+ kinds = {type(item) for item in items}
410
+ return kinds.pop() if len(kinds) == 1 and _is_model(items[0]) else None
411
+
412
+
413
+ def _decoded_container(text: str | bytes | bytearray) -> object:
414
+ """What JSON *text* decodes to, decoded again while that is text, when it ends in a mapping or a list; else *text*.
415
+
416
+ A model read from JSON text (`Json[...]`) is walked beside what the text holds. Text that is not JSON, or nests
417
+ deeper than the decoder goes, is left as it is.
418
+ """
419
+ try:
420
+ current = json.loads(text)
421
+ while isinstance(current, str):
422
+ current = json.loads(current)
423
+ except (ValueError, RecursionError):
424
+ return text
425
+ return current if isinstance(current, (dict, list)) else text
426
+
427
+
428
+ def _refuse_if_key_hides(raw: object, value: object, path: str, reason: str) -> None:
429
+ """Refuse a part no reading pairs with what it became, when a model was built there and a key could hide in it.
430
+
431
+ A number, a model given as is, an object read by attributes and text that is not JSON hold no key to drift.
432
+ """
433
+ if _holds(value, _builds_model) and _holds(raw, _is_mapping, read_text=True):
434
+ raise UncheckableDriftError(path, reason)
435
+
436
+
437
+ def _holds(value: object, found: Callable[[object], bool], *, read_text: bool = False) -> bool:
438
+ """Whether *value*, or anything inside its containers (a dict's keys too), is *found*.
439
+
440
+ With *read_text*, text counts as the JSON it decodes to, decoded again while that is text, and two things count
441
+ as found, since what they hold cannot be told: text nesting deeper than the decoder goes, and an iterable that is
442
+ no collection, which validation may have read up or may answer differently when read again. Every part stays
443
+ referenced until the search ends, so the id of one already seen is never taken by a part decoded later.
444
+ """
445
+ pending, visited = [value], {id(value): value}
446
+ while pending:
447
+ current = pending.pop()
448
+ if read_text and isinstance(current, (str, bytes, bytearray)):
449
+ try:
450
+ pending.append(json.loads(current))
451
+ except ValueError:
452
+ pass
453
+ except RecursionError:
454
+ return True
455
+ continue
456
+ elif read_text and _read_once(current):
457
+ return True
458
+ if found(current):
459
+ return True
460
+ for part in _parts(current):
461
+ if id(part) not in visited:
462
+ visited[id(part)] = part
463
+ pending.append(part)
464
+ return False
465
+
466
+
467
+ def _is_mapping(value: object) -> bool:
468
+ return isinstance(value, (dict, collections.abc.Mapping))
469
+
470
+
471
+ def _builds_model(value: object) -> bool:
472
+ """A model, or an iterator validation left lazy, which builds its models only as it is read."""
473
+ return _is_model(value) or isinstance(value, collections.abc.Iterator)
474
+
475
+
476
+ def _read_once(value: object) -> bool:
477
+ """An iterable that is no collection and no model given as is: what it held cannot be read again."""
478
+ return (
479
+ isinstance(value, collections.abc.Iterable)
480
+ and not isinstance(value, collections.abc.Collection)
481
+ and not _is_model(value)
482
+ )
483
+
484
+
485
+ def _items_of(value: object) -> list[Any] | None:
486
+ """The items of a container read again as often as asked, not text and not a mapping; ``None`` for the rest."""
487
+ if isinstance(value, collections.abc.Collection) and not isinstance(
488
+ value, (str, bytes, bytearray, collections.abc.Mapping)
489
+ ):
490
+ return list(value)
491
+ return None
492
+
493
+
494
+ def _parts(value: object) -> Any:
495
+ """What a container holds, a mapping's keys too, or nothing for anything else."""
496
+ if isinstance(value, (list, tuple, set, frozenset)):
497
+ return value
498
+ if isinstance(value, (dict, collections.abc.Mapping)):
499
+ return [*value, *value.values()]
500
+ return _items_of(value) or ()
501
+
502
+
503
+ def shape(value: object, _seen: frozenset[int] = frozenset()) -> object:
504
+ """The structural shape of a value: paths and type *categories*, never values.
505
+
506
+ Numbers collapse to one category (so ``5`` and ``5.0`` do not read as drift) and ``None`` becomes
507
+ ``"null"`` (a nullable wildcard). A list becomes a single merged element shape. This is what a
508
+ contract snapshot stores, so later runs pass when values change but fail on structural drift.
509
+ """
510
+ if value is None:
511
+ return "null"
512
+ if isinstance(value, bool):
513
+ return "bool"
514
+ if isinstance(value, (int, float)):
515
+ return "number"
516
+ if isinstance(value, str):
517
+ return "str"
518
+ if isinstance(value, (dict, list, tuple)):
519
+ if id(value) in _seen:
520
+ # a self-referential graph would otherwise recurse until the interpreter gives up
521
+ return "<circular ref>"
522
+ nested = _seen | {id(value)}
523
+ if isinstance(value, dict):
524
+ return {key: shape(item, nested) for key, item in value.items()}
525
+ element_shapes = [shape(item, nested) for item in value]
526
+ return [reduce(_merge, element_shapes)] if element_shapes else []
527
+ return type(value).__name__
528
+
529
+
530
+ def _merge(left: Any, right: Any) -> Any:
531
+ """Merge two element shapes into one representative shape (``null`` yields to a concrete type)."""
532
+ if left == right:
533
+ return left
534
+ if left == "null":
535
+ return right
536
+ if right == "null":
537
+ return left
538
+ if isinstance(left, dict) and isinstance(right, dict):
539
+ return {key: _merge(left.get(key, "null"), right.get(key, "null")) for key in set(left) | set(right)}
540
+ if isinstance(left, list) and isinstance(right, list):
541
+ if not left:
542
+ return right
543
+ if not right:
544
+ return left
545
+ return [_merge(left[0], right[0])]
546
+ return "mixed"
547
+
548
+
549
+ def _shape_name(part: object) -> str:
550
+ if isinstance(part, str):
551
+ return part
552
+ return "object" if isinstance(part, dict) else "list"
553
+
554
+
555
+ def _join(path: str, key: str) -> str:
556
+ return f"{path}.{key}" if path else key
557
+
558
+
559
+ def shape_diff(old: Any, new: Any, path: str = "") -> list[tuple[str, str, str]]:
560
+ """Structural drift between two shapes: ``(kind, path, detail)`` for added / removed / retyped leaves.
561
+
562
+ A ``null`` on either side is a nullable wildcard and never counts as drift.
563
+ """
564
+ if old == new or old == "null" or new == "null":
565
+ return []
566
+ if isinstance(old, dict) and isinstance(new, dict):
567
+ drift: list[tuple[str, str, str]] = [("added", _join(path, key), "") for key in new if key not in old]
568
+ for key in old:
569
+ child = _join(path, key)
570
+ if key not in new:
571
+ drift.append(("removed", child, ""))
572
+ else:
573
+ drift += shape_diff(old[key], new[key], child)
574
+ return drift
575
+ if isinstance(old, list) and isinstance(new, list):
576
+ if not old or not new:
577
+ return []
578
+ return shape_diff(old[0], new[0], f"{path}[*]")
579
+ return [("retyped", path, f"{_shape_name(old)} -> {_shape_name(new)}")]