assertpy2 2.27.0__tar.gz → 2.27.1__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 (248) hide show
  1. {assertpy2-2.27.0 → assertpy2-2.27.1}/CONTRIBUTING.md +14 -0
  2. {assertpy2-2.27.0 → assertpy2-2.27.1}/PKG-INFO +2 -2
  3. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_capable_typing.py +37 -31
  4. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_compare.py +60 -24
  5. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_diff.py +47 -14
  6. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_equality.py +69 -25
  7. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_introspection.py +150 -5
  8. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_hints.py +14 -9
  9. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_satisfies.py +4 -2
  10. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_snapshot_codec.py +100 -6
  11. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/assertpy.py +1 -1
  12. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/helpers.py +19 -4
  13. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/numeric.py +41 -22
  14. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/pytest_plugin.py +5 -2
  15. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/concepts/stability.md +1 -1
  16. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/assertions.md +3 -1
  17. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/errors.md +1 -1
  18. {assertpy2-2.27.0 → assertpy2-2.27.1}/mkdocs.yml +1 -1
  19. {assertpy2-2.27.0 → assertpy2-2.27.1}/pyproject.toml +2 -2
  20. {assertpy2-2.27.0 → assertpy2-2.27.1}/scripts/generate_poll_protocols.py +59 -5
  21. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/api_snapshot.json +17 -21
  22. assertpy2-2.27.1/tests/test_attrs_support.py +478 -0
  23. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_capable_protocol.py +413 -0
  24. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_complexity.py +2 -3
  25. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_equals.py +12 -0
  26. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_hints.py +20 -0
  27. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_numbers.py +83 -0
  28. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_pin_coverage.py +4 -0
  29. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_property_attrs.py +43 -1
  30. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_property_based.py +29 -0
  31. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_pytest_plugin.py +8 -5
  32. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_recursive_compare_config.py +35 -0
  33. assertpy2-2.27.1/tests/test_recursive_compare_pydantic.py +466 -0
  34. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing.py +22 -4
  35. assertpy2-2.27.1/tests/test_verdict_corpus.py +137 -0
  36. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_cases.py +1 -0
  37. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_negative_baseline.py +2 -0
  38. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_promises.py +35 -0
  39. assertpy2-2.27.1/tests/verdict_corpus.py +632 -0
  40. assertpy2-2.27.1/tests/verdict_golden.txt +127 -0
  41. assertpy2-2.27.0/tests/test_attrs_support.py +0 -170
  42. assertpy2-2.27.0/tests/test_recursive_compare_pydantic.py +0 -83
  43. {assertpy2-2.27.0 → assertpy2-2.27.1}/.gitignore +0 -0
  44. {assertpy2-2.27.0 → assertpy2-2.27.1}/LICENSE +0 -0
  45. {assertpy2-2.27.0 → assertpy2-2.27.1}/README.md +0 -0
  46. {assertpy2-2.27.0 → assertpy2-2.27.1}/SECURITY.md +0 -0
  47. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/__init__.py +0 -0
  48. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_clustering.py +0 -0
  49. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_dangling.py +0 -0
  50. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/__init__.py +0 -0
  51. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_builder_check_typing.py +0 -0
  52. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_check_typing.py +0 -0
  53. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_compat.py +0 -0
  54. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_contract.py +0 -0
  55. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_membership.py +0 -0
  56. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_mixin_base.py +0 -0
  57. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_negated_typing.py +0 -0
  58. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_operations.py +0 -0
  59. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_ordering.py +0 -0
  60. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_pairing.py +0 -0
  61. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_path.py +0 -0
  62. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_poll_typing.py +0 -0
  63. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_require.py +0 -0
  64. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_size.py +0 -0
  65. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_text.py +0 -0
  66. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_typing.py +0 -0
  67. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_inline.py +0 -0
  68. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_matcher_impls.py +0 -0
  69. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/async_assertions.py +0 -0
  70. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/base.py +0 -0
  71. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/behave_matchers.py +0 -0
  72. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/bytes_mixin.py +0 -0
  73. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/collection.py +0 -0
  74. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/contains.py +0 -0
  75. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/dataframe.py +0 -0
  76. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/date.py +0 -0
  77. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/dict.py +0 -0
  78. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/dynamic.py +0 -0
  79. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/errors.py +0 -0
  80. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/exception.py +0 -0
  81. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/extracting.py +0 -0
  82. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/file.py +0 -0
  83. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/http_mixin.py +0 -0
  84. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/json_mixin.py +0 -0
  85. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/matchers.py +0 -0
  86. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/outcome.py +0 -0
  87. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/py.typed +0 -0
  88. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/snapshot.py +0 -0
  89. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/string.py +0 -0
  90. {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/warning.py +0 -0
  91. {assertpy2-2.27.0 → assertpy2-2.27.1}/benchmarks/test_perf.py +0 -0
  92. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-equal.png +0 -0
  93. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-equal.svg +0 -0
  94. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-gallery.png +0 -0
  95. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-match.svg +0 -0
  96. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-sequence.svg +0 -0
  97. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-set.svg +0 -0
  98. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-string.svg +0 -0
  99. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/concepts/type-safety.md +0 -0
  100. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/extending/custom-assertions.md +0 -0
  101. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/extending/integrations.md +0 -0
  102. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/getting-started/comparison.md +0 -0
  103. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/getting-started/migration.md +0 -0
  104. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/getting-started/quickstart.md +0 -0
  105. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/data.md +0 -0
  106. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/fluent.md +0 -0
  107. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/matchers.md +0 -0
  108. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/testing.md +0 -0
  109. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/index.md +0 -0
  110. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/logo-dark.svg +0 -0
  111. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/logo.svg +0 -0
  112. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/recipes.md +0 -0
  113. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/async.md +0 -0
  114. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/bytes.md +0 -0
  115. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/collections.md +0 -0
  116. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/containment.md +0 -0
  117. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/core.md +0 -0
  118. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/dataframes.md +0 -0
  119. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/dates.md +0 -0
  120. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/dicts.md +0 -0
  121. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/dynamic.md +0 -0
  122. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/entry-points.md +0 -0
  123. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/errors.md +0 -0
  124. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/exceptions.md +0 -0
  125. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/extracting.md +0 -0
  126. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/files.md +0 -0
  127. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/http.md +0 -0
  128. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/json.md +0 -0
  129. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/matchers.md +0 -0
  130. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/numbers.md +0 -0
  131. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/overview.md +0 -0
  132. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/snapshots.md +0 -0
  133. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/strings.md +0 -0
  134. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/warnings.md +0 -0
  135. {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/stylesheets/extra.css +0 -0
  136. {assertpy2-2.27.0 → assertpy2-2.27.1}/mkdocs_hooks.py +0 -0
  137. {assertpy2-2.27.0 → assertpy2-2.27.1}/scripts/generate_check_protocols.py +0 -0
  138. {assertpy2-2.27.0 → assertpy2-2.27.1}/scripts/mutation_report.py +0 -0
  139. {assertpy2-2.27.0 → assertpy2-2.27.1}/scripts/regenerate.py +0 -0
  140. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/api_surface.py +0 -0
  141. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/conftest.py +0 -0
  142. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/docs_fixtures.py +0 -0
  143. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/group_compat.py +0 -0
  144. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/poll_parity_baseline.py +0 -0
  145. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/poll_parity_cases.py +0 -0
  146. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/pyright_baseline.py +0 -0
  147. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_api_compatibility.py +0 -0
  148. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_api_vocabulary.py +0 -0
  149. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_architecture_doc.py +0 -0
  150. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_async.py +0 -0
  151. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_async_predicates.py +0 -0
  152. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_behave_matchers.py +0 -0
  153. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_bool.py +0 -0
  154. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_boundary_cases.py +0 -0
  155. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_bytes.py +0 -0
  156. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_callable.py +0 -0
  157. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_chaining.py +0 -0
  158. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_check.py +0 -0
  159. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_check_protocols.py +0 -0
  160. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_class.py +0 -0
  161. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_clustering.py +0 -0
  162. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_collection.py +0 -0
  163. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_core.py +0 -0
  164. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_custom_dict.py +0 -0
  165. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_custom_list.py +0 -0
  166. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dangling.py +0 -0
  167. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dataframe.py +0 -0
  168. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_datetime.py +0 -0
  169. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_declared_floors.py +0 -0
  170. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_description.py +0 -0
  171. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dict.py +0 -0
  172. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dict_compare.py +0 -0
  173. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_docs_examples.py +0 -0
  174. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_docs_typing.py +0 -0
  175. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dynamic.py +0 -0
  176. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_errors.py +0 -0
  177. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_evaluation_core.py +0 -0
  178. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_exception_context.py +0 -0
  179. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_expected_contract.py +0 -0
  180. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_expected_exception.py +0 -0
  181. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_expected_warning.py +0 -0
  182. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_extensions.py +0 -0
  183. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_extracting.py +0 -0
  184. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_fail.py +0 -0
  185. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_file.py +0 -0
  186. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_grouped_soft.py +0 -0
  187. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_hostile_values.py +0 -0
  188. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_http.py +0 -0
  189. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_in.py +0 -0
  190. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_inline_record.py +0 -0
  191. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_inline_snapshot.py +0 -0
  192. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_iterable_cluster.py +0 -0
  193. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_json.py +0 -0
  194. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_list.py +0 -0
  195. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_match_result.py +0 -0
  196. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_matcher_parity.py +0 -0
  197. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_matcher_registry.py +0 -0
  198. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_matchers.py +0 -0
  199. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_message_elision.py +0 -0
  200. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_namedtuple.py +0 -0
  201. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_negated_protocols.py +0 -0
  202. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_none.py +0 -0
  203. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_not.py +0 -0
  204. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_openapi_contract.py +0 -0
  205. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_operation_contract.py +0 -0
  206. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_optional_integration_contracts.py +0 -0
  207. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_overload_order.py +0 -0
  208. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_overloads.py +0 -0
  209. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_pipeline.py +0 -0
  210. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_poll_parity.py +0 -0
  211. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_poll_protocols.py +0 -0
  212. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_protocol_parity.py +0 -0
  213. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_public_surface.py +0 -0
  214. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_pyright_baseline.py +0 -0
  215. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_recursive_assertion.py +0 -0
  216. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_recursive_compare.py +0 -0
  217. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_recursive_compare_attrs.py +0 -0
  218. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_refusals.py +0 -0
  219. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_regex_groups.py +0 -0
  220. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_requirement.py +0 -0
  221. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_rich_diff.py +0 -0
  222. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_same_as.py +0 -0
  223. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_satisfy.py +0 -0
  224. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_skip_guard.py +0 -0
  225. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_snapshots.py +0 -0
  226. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_soft.py +0 -0
  227. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_soft_fail.py +0 -0
  228. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_string.py +0 -0
  229. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_structural.py +0 -0
  230. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_surface_conformance.py +0 -0
  231. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_sync_eventually.py +0 -0
  232. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_traceback.py +0 -0
  233. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_type.py +0 -0
  234. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_type_expression_failures.py +0 -0
  235. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_claims.py +0 -0
  236. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_completeness.py +0 -0
  237. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_conformance.py +0 -0
  238. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_from_a_wheel.py +0 -0
  239. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_http.py +0 -0
  240. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_integrations.py +0 -0
  241. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_negative.py +0 -0
  242. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_promises.py +0 -0
  243. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_vacuity_contract.py +0 -0
  244. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_warn.py +0 -0
  245. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_harness.py +0 -0
  246. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_http.py +0 -0
  247. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_integrations.py +0 -0
  248. {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_integrations_baseline.py +0 -0
@@ -114,6 +114,20 @@ change. The baseline test passes the same target itself, so it gives one answer
114
114
  - `tests/test_docs_examples.py` is run separately because it executes the snippets in `docs/`, which
115
115
  needs the `docs-examples` group and a different collection.
116
116
 
117
+ ### When a gate says an answer moved
118
+
119
+ Two gates record what the library currently decides rather than what it should. Both fail on a change
120
+ and neither is a failure to paper over: read the diff, decide whether the change is meant to ship, then
121
+ re-record it in the same commit so the diff carries the decision into review.
122
+
123
+ ```bash
124
+ ASSERTPY2_UPDATE_API=1 uv run pytest tests/test_api_compatibility.py # the public surface
125
+ ASSERTPY2_UPDATE_VERDICTS=1 uv run pytest tests/test_verdict_corpus.py # what each input decides
126
+ ```
127
+
128
+ The second one's diff is the draft of the release's Behaviour changes section, so read it before
129
+ writing that section rather than after.
130
+
117
131
  ## Commit style
118
132
 
119
133
  Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, etc.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: assertpy2
3
- Version: 2.27.0
4
- Summary: Fluent assertion library for Python with composable matchers, structural matching, and full type safety
3
+ Version: 2.27.1
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
7
7
  Project-URL: Issues, https://github.com/Solganis/assertpy2/issues
@@ -35,8 +35,18 @@ if TYPE_CHECKING:
35
35
  from ..matchers import Matcher
36
36
  from ._builder_check_typing import _CheckAnyValue
37
37
  from ._compat import Self
38
- from ._poll_typing import _AsyncPoll, _SyncPoll
39
- from ._typing import _E, _K, _R, _U, _V
38
+ from ._poll_typing import _AsyncPoll, _NoExpectationOnAChain, _SyncPoll
39
+ from ._typing import (
40
+ _E,
41
+ _K,
42
+ _R,
43
+ _U,
44
+ _V,
45
+ _ExpectedCompletionAssertion,
46
+ _ExpectedRaiseAssertion,
47
+ _ExpectedWarningAssertion,
48
+ _ListAssertion,
49
+ )
40
50
 
41
51
  # covariant: the façade only ever hands the subject back, through `value`
42
52
  _CapableT_co = TypeVar("_CapableT_co", covariant=True)
@@ -194,6 +204,18 @@ if TYPE_CHECKING:
194
204
  ignoring: type[Exception] | tuple[type[Exception], ...] = ...,
195
205
  trace: bool = ...,
196
206
  ) -> _SyncPoll[Any]: ...
207
+ # where the callable view lands: flattened, the runtime's `-> Self` kept a caught message typed as the callable
208
+ def raises(self: _CapableAssertion[_Callable], ex: type) -> _ExpectedRaiseAssertion[Any]: ...
209
+ def does_not_raise(self: _CapableAssertion[_Callable], ex: type) -> _ExpectedCompletionAssertion[Any]: ...
210
+ def warns(
211
+ self: _CapableAssertion[_Callable], warning: type[Warning] = ...
212
+ ) -> _ExpectedWarningAssertion[Any]: ...
213
+ def does_not_warn(
214
+ self: _CapableAssertion[_Callable], warning: type[Warning] = ...
215
+ ) -> _ExpectedCompletionAssertion[Any]: ...
216
+
217
+ when_called_with: _NoExpectationOnAChain
218
+
197
219
  # the builder's own helpers: left to `__getattr__`, `builder()` read as this facade over the value already here
198
220
  def builder(
199
221
  self,
@@ -220,8 +242,8 @@ if TYPE_CHECKING:
220
242
  ) -> Self: ...
221
243
  def all_satisfy(self, matcher: Matcher[Any] | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
222
244
  def any_satisfy(self, matcher: Matcher[Any] | Callable[..., bool]) -> Self: ...
223
- def at_json_path(self, path: str) -> Self: ...
224
- def caused_by(self, ex: type) -> Self: ...
245
+ def at_json_path(self, path: str) -> AssertionBuilder[Any]: ...
246
+ def caused_by(self, ex: type) -> AssertionBuilder[Any]: ...
225
247
  def conforms_to_openapi(
226
248
  self,
227
249
  spec: dict[str, Any],
@@ -255,9 +277,7 @@ if TYPE_CHECKING:
255
277
  def contains_value(
256
278
  self: _CapableAssertion[_KeyedWithValues], *values: object
257
279
  ) -> _CapableAssertion[_CapableT_co]: ...
258
- def decoded_as(
259
- self: _CapableAssertion[bytes | bytearray], encoding: str = ...
260
- ) -> _CapableAssertion[_CapableT_co]: ...
280
+ def decoded_as(self: _CapableAssertion[bytes | bytearray], encoding: str = ...) -> AssertionBuilder[Any]: ...
261
281
  def decoded_as_json(self) -> AssertionBuilder[object]: ...
262
282
  def described_as(self, description: str) -> Self: ...
263
283
  def does_not_contain(self, *items: object) -> Self: ...
@@ -273,28 +293,24 @@ if TYPE_CHECKING:
273
293
  def does_not_exist(self: _CapableAssertion[_PathLike]) -> _CapableAssertion[_CapableT_co]: ...
274
294
  def does_not_have_json_path(self, path: str) -> Self: ...
275
295
  def does_not_match(self: _CapableAssertion[str], pattern: str) -> _CapableAssertion[_CapableT_co]: ...
276
- def does_not_raise(self: _CapableAssertion[_Callable], ex: type) -> _CapableAssertion[_CapableT_co]: ...
277
- def does_not_warn(
278
- self: _CapableAssertion[_Callable], warning: type[Warning] = ...
279
- ) -> _CapableAssertion[_CapableT_co]: ...
280
296
  def each(self, matcher: Matcher[Any] | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
281
297
  def ends_with(self, suffix: object) -> Self: ...
282
298
  def ends_with_ignoring_case(self: _CapableAssertion[str], suffix: str) -> _CapableAssertion[_CapableT_co]: ...
283
- def error_of(self, ex: type) -> Self: ...
284
- def errors(self) -> Self: ...
299
+ def error_of(self, ex: type) -> AssertionBuilder[Any]: ...
300
+ def errors(self) -> AssertionBuilder[Any]: ...
285
301
  def exists(self: _CapableAssertion[_PathLike]) -> _CapableAssertion[_CapableT_co]: ...
286
- def extracting(self, *names: object, **kwargs: object) -> Self: ...
302
+ def extracting(self, *names: object, **kwargs: object) -> _ListAssertion[Any]: ...
287
303
  def extracting_group(
288
304
  self: _CapableAssertion[str], pattern: str, group: int | str = ...
289
- ) -> _CapableAssertion[_CapableT_co]: ...
290
- def filtered_on(self, predicate: Matcher[Any] | Callable[[Any], bool]) -> Self: ...
291
- def flat_mapped(self, func: Callable[[Any], Iterable[Any]]) -> Self: ...
305
+ ) -> AssertionBuilder[Any]: ...
306
+ def filtered_on(self, predicate: Matcher[Any] | Callable[[Any], bool]) -> _ListAssertion[Any]: ...
307
+ def flat_mapped(self, func: Callable[[Any], Iterable[Any]]) -> _ListAssertion[Any]: ...
292
308
  def has_byte_at(
293
309
  self: _CapableAssertion[bytes | bytearray], index: int, expected: int
294
310
  ) -> _CapableAssertion[_CapableT_co]: ...
295
311
  def has_json_path(self, path: str) -> Self: ...
296
312
  def has_no_none_fields(self, *, allow_empty: bool = ...) -> Self: ...
297
- def has_root_cause(self, ex: type) -> Self: ...
313
+ def has_root_cause(self, ex: type) -> AssertionBuilder[Any]: ...
298
314
  def has_same_size_as(self, other: Sized) -> Self: ...
299
315
  def has_size_between(self, low: int, high: int) -> Self: ...
300
316
  def has_size_greater_than(self, size: int) -> Self: ...
@@ -420,11 +436,10 @@ if TYPE_CHECKING:
420
436
  def matches_json_schema(self, schema: dict[str, Any]) -> Self: ...
421
437
  def matches_json_schema_from_file(self, path: str | Path) -> Self: ...
422
438
  def matches_structure(self, spec: dict[Any, Any]) -> Self: ...
423
- def matches_with_groups(self: _CapableAssertion[str], pattern: str) -> _CapableAssertion[_CapableT_co]: ...
439
+ def matches_with_groups(self: _CapableAssertion[str], pattern: str) -> AssertionBuilder[Any]: ...
424
440
  def none_satisfy(self, matcher: Matcher[Any] | Callable[..., bool]) -> Self: ...
425
- def raised(self) -> Self: ...
426
- def raises(self: _CapableAssertion[_Callable], ex: type) -> _CapableAssertion[_CapableT_co]: ...
427
- def returned(self) -> Self: ...
441
+ def raised(self) -> AssertionBuilder[Any]: ...
442
+ def returned(self) -> AssertionBuilder[Any]: ...
428
443
  def satisfies_exactly(self, *matchers: Matcher[Any] | Callable[..., bool]) -> Self: ...
429
444
  def satisfies_exactly_in_any_order(self, *matchers: Matcher[Any] | Callable[..., bool]) -> Self: ...
430
445
  def snapshot(
@@ -443,12 +458,6 @@ if TYPE_CHECKING:
443
458
  self: _CapableAssertion[bytes | bytearray], prefix: bytes | bytearray
444
459
  ) -> _CapableAssertion[_CapableT_co]: ...
445
460
  def starts_with_ignoring_case(self: _CapableAssertion[str], prefix: str) -> _CapableAssertion[_CapableT_co]: ...
446
- def warns(
447
- self: _CapableAssertion[_Callable], warning: type[Warning] = ...
448
- ) -> _CapableAssertion[_CapableT_co]: ...
449
- def when_called_with(
450
- self: _CapableAssertion[_Callable], *some_args: object, **some_kwargs: object
451
- ) -> _CapableAssertion[_CapableT_co]: ...
452
461
  def zip_satisfies(
453
462
  self, other: Iterable[object], predicate: Callable[..., bool], *, allow_empty: bool = ...
454
463
  ) -> Self: ...
@@ -721,9 +730,6 @@ if TYPE_CHECKING:
721
730
  def starts_with_ignoring_case(
722
731
  self: _NegatedCapableAssertion[str], prefix: str
723
732
  ) -> _CapableAssertion[_CapableT_co]: ...
724
- def when_called_with(
725
- self: _NegatedCapableAssertion[_Callable], *some_args: object, **some_kwargs: object
726
- ) -> _CapableAssertion[_CapableT_co]: ...
727
733
  def zip_satisfies(
728
734
  self, other: Iterable[object], predicate: Callable[..., bool], *, allow_empty: bool = ...
729
735
  ) -> _CapableAssertion[_CapableT_co]: ...
@@ -26,12 +26,21 @@ import uuid
26
26
  from dataclasses import dataclass
27
27
  from typing import TYPE_CHECKING, Any
28
28
 
29
- from ._introspection import is_attrs_instance, is_mapping_like, is_model_dump_object
29
+ from ._introspection import (
30
+ KeyedValue,
31
+ as_held,
32
+ eq_keyed,
33
+ is_attrs_instance,
34
+ is_mapping_like,
35
+ is_model_dump_object,
36
+ kind_of,
37
+ model_field_values,
38
+ )
30
39
  from ._ordering import nan_operand
31
40
  from ._require import raised_inside, verdict
32
41
 
33
42
  if TYPE_CHECKING:
34
- from collections.abc import Callable
43
+ from collections.abc import Callable, Iterable
35
44
 
36
45
 
37
46
  _EQ_ATOMIC = frozenset(
@@ -164,33 +173,56 @@ def _find_ambiguous_operand(actual, expected, _seen=None):
164
173
  operand = _ambiguous_array_operand(actual, expected)
165
174
  if operand is not None:
166
175
  return operand
176
+ for left, right in _members_compared(actual, expected):
177
+ found = _find_ambiguous_operand(left, right, _seen)
178
+ if found is not None:
179
+ return found
180
+ return None
181
+
182
+
183
+ def _members_compared(actual: Any, expected: Any) -> Iterable[tuple[Any, Any]]:
184
+ """The pairs ``==`` walks into for two values of one container kind, and none for any other pair."""
167
185
  if is_mapping_like(actual) and is_mapping_like(expected):
168
186
  expected_keys = set(expected)
169
- for key in actual:
170
- if key in expected_keys:
171
- found = _find_ambiguous_operand(actual[key], expected[key], _seen)
172
- if found is not None:
173
- return found
174
- return None
187
+ return ((actual[key], expected[key]) for key in actual if key in expected_keys)
175
188
  if (
176
189
  dataclasses.is_dataclass(actual)
177
190
  and not isinstance(actual, type)
178
191
  and dataclasses.is_dataclass(expected)
179
192
  and not isinstance(expected, type)
180
193
  ):
181
- for field in dataclasses.fields(actual):
182
- found = _find_ambiguous_operand(getattr(actual, field.name), getattr(expected, field.name, None), _seen)
183
- if found is not None:
184
- return found
185
- return None
194
+ return (
195
+ (getattr(actual, one.name), getattr(expected, one.name, None))
196
+ for one in dataclasses.fields(actual)
197
+ if one.compare
198
+ )
199
+ if is_attrs_instance(actual) and is_attrs_instance(expected):
200
+ return _keyed_members(actual, expected)
186
201
  if is_model_dump_object(actual) and is_model_dump_object(expected):
187
- return _find_ambiguous_operand(actual.model_dump(), expected.model_dump(), _seen)
202
+ return ((model_field_values(actual), model_field_values(expected)),)
188
203
  if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
189
- for actual_item, expected_item in zip(actual, expected, strict=False):
190
- found = _find_ambiguous_operand(actual_item, expected_item, _seen)
191
- if found is not None:
192
- return found
193
- return None
204
+ return zip(actual, expected, strict=False)
205
+ return ()
206
+
207
+
208
+ def _keyed_members(actual: Any, expected: Any) -> tuple[tuple[Any, Any], ...]:
209
+ """The attrs fields ``==`` compares, each read through its key as ``==`` reads it, or none.
210
+
211
+ Only what ``==`` compares can have broken it. attrs reads every key before it compares a field, so a key
212
+ that raises is the error being explained and no array is to blame: read again here, it raised a second
213
+ error while the first was handled, and skipped, it let a later array take the blame.
214
+ """
215
+ try:
216
+ return tuple(
217
+ (
218
+ eq_keyed(one, getattr(actual, one.name)),
219
+ eq_keyed(one, getattr(expected, one.name)) if hasattr(expected, one.name) else None,
220
+ )
221
+ for one in actual.__attrs_attrs__
222
+ if one.eq is not False
223
+ )
224
+ except (TypeError, ValueError): # the two errors `_guarded_not_equal` hands to this search
225
+ return ()
194
226
 
195
227
 
196
228
  def _guarded_not_equal(actual, expected, *, method="is_equal_to") -> bool:
@@ -306,6 +338,7 @@ def _resolve_comparator(actual, config: _CompareConfig, *, field):
306
338
  comparators = config.comparators
307
339
  if comparators is None:
308
340
  return None
341
+ actual = as_held(actual)
309
342
  if field is not None and not isinstance(field, type) and field in comparators:
310
343
  return comparators[field]
311
344
  if type(actual) in comparators:
@@ -326,7 +359,7 @@ def _types_differ(actual, expected) -> bool:
326
359
  ``_is_matcher`` is imported here rather than at module scope because ``_matcher_impls`` imports
327
360
  ``_guarded_not_equal`` from this module, so the module-level import would be a cycle.
328
361
  """
329
- if type(actual) is type(expected):
362
+ if kind_of(actual) is kind_of(expected):
330
363
  return False
331
364
  from .._matcher_impls import _is_matcher
332
365
 
@@ -412,11 +445,12 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
412
445
  `assertpy2._engine._diff._child_entries()` is the single place to know.
413
446
  """
414
447
  if config is not None:
415
- if config.ignore_null and field is not None and expected is None:
448
+ if config.ignore_null and field is not None and as_held(expected) is None:
416
449
  return "equal" # a named field the expected side leaves None is not compared
417
450
  comparator = _resolve_comparator(actual, config, field=field)
418
451
  if comparator is not None:
419
- return "equal" if verdict(comparator(actual, expected), subject="the comparator") else "leaf"
452
+ agreed = verdict(comparator(as_held(actual), as_held(expected)), subject="the comparator")
453
+ return "equal" if agreed else "leaf"
420
454
  if config.strict_types:
421
455
  if actual is expected and not at_root:
422
456
  # identity, free from `PyObject_RichCompareBool`. Not at the root, where it made `strict_types` weaker
@@ -430,8 +464,10 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
430
464
  if type(actual) not in _EQ_ATOMIC and not _guarded_not_equal(actual, expected):
431
465
  # `[True] == [1]`: a container says nothing about the types inside it, so the walk keeps going
432
466
  return "strict"
433
- if config.tolerance is not None and _is_real_number(actual) and _is_real_number(expected):
434
- return "equal" if _within_tolerance(actual, expected, config.tolerance) else "leaf"
467
+ if config.tolerance is not None and _is_real_number(as_held(actual)) and _is_real_number(as_held(expected)):
468
+ # a keyed field's key still holds equal what the tolerance would not: it only ever loosens `==`
469
+ within = _within_tolerance(as_held(actual), as_held(expected), config.tolerance)
470
+ return "equal" if within or (type(actual) is KeyedValue and actual == expected) else "leaf"
435
471
  return _plain_decision(actual, expected, config, at_root=at_root)
436
472
 
437
473
 
@@ -34,7 +34,17 @@ from typing import TYPE_CHECKING, TypeVar
34
34
 
35
35
  from ..errors import DiffEntry, DiffResult, _safe_repr
36
36
  from ._compare import _guarded_not_equal, _node_decision
37
- from ._introspection import is_attrs_instance, is_mapping_like, is_model_dump_object, is_namedtuple, keyed_snapshot
37
+ from ._introspection import (
38
+ TakenApart,
39
+ is_attrs_instance,
40
+ is_mapping_like,
41
+ is_model_dump_object,
42
+ is_namedtuple,
43
+ keyed_names,
44
+ keyed_pair,
45
+ keyed_snapshot,
46
+ model_field_values,
47
+ )
38
48
  from ._path import _ROOT, _Path
39
49
 
40
50
  if TYPE_CHECKING:
@@ -46,10 +56,20 @@ __tracebackhide__ = True
46
56
 
47
57
 
48
58
  def _field_dict(obj, is_model):
49
- """Field mapping of a pydantic-style model (``model_dump()``) or an attrs instance (shallow)."""
59
+ """Field mapping of a pydantic-style model or an attrs instance (shallow), as the values its fields hold.
60
+
61
+ An attrs instance's carries the keys its fields are compared through, so the walk decides a field by the
62
+ key both sides declare and still shows the values held: ``str.lower`` would otherwise print a value
63
+ neither side has, and listed a field ``==`` holds equal.
64
+ """
50
65
  if is_model:
51
- return obj.model_dump()
52
- return {field.name: getattr(obj, field.name) for field in obj.__attrs_attrs__ if field.eq is not False}
66
+ return model_field_values(obj)
67
+ compared = [field for field in obj.__attrs_attrs__ if field.eq is not False]
68
+ return TakenApart(
69
+ type(obj),
70
+ {field.name: getattr(obj, field.name) for field in compared},
71
+ {field.name: field.eq_key for field in compared if getattr(field, "eq_key", None) is not None},
72
+ )
53
73
 
54
74
 
55
75
  def _child_entries(actual, expected, path: _Path, *, descended_for, _seen=None, config=None) -> list[DiffEntry]:
@@ -422,8 +442,8 @@ def _build_equality_diff(
422
442
  both_model = is_model_dump_object(actual) and is_model_dump_object(expected)
423
443
  both_attrs = is_attrs_instance(actual) and is_attrs_instance(expected)
424
444
  if both_model or both_attrs:
425
- actual_dict = _field_dict(actual, both_model)
426
- expected_dict = _field_dict(expected, both_model)
445
+ actual_dict, expected_dict = _field_dict(actual, both_model), _field_dict(expected, both_model)
446
+ keyed = keyed_names(actual_dict, expected_dict)
427
447
  entries = []
428
448
  for key in _ordered_keys(actual_dict, expected_dict):
429
449
  if key not in expected_dict:
@@ -431,7 +451,11 @@ def _build_equality_diff(
431
451
  elif key not in actual_dict:
432
452
  entries.append(_prefix.attr(key).entry(actual=None, absent="actual", expected=expected_dict[key]))
433
453
  else:
434
- decision = _node_decision(actual_dict[key], expected_dict[key], config, field=key)
454
+ decision = (
455
+ _node_decision(*keyed_pair(actual_dict, expected_dict, key), config, field=key)
456
+ if key in keyed
457
+ else _node_decision(actual_dict[key], expected_dict[key], config, field=key)
458
+ )
435
459
  if decision == "leaf":
436
460
  entries.append(_prefix.attr(key).entry(actual=actual_dict[key], expected=expected_dict[key]))
437
461
  elif decision != "equal":
@@ -495,6 +519,7 @@ def _mapping_diff_entries(actual, expected, prefix: _Path, child_seen: set[int],
495
519
  actual_keys = set(kept)
496
520
  expected_keys = set(kept_expected)
497
521
  entries.extend(_order_entries(actual, expected, kept, kept_expected, prefix))
522
+ keyed = keyed_names(kept, kept_expected)
498
523
  if config is not None and config.strict_types:
499
524
  # `{True} & {1}` hands back whichever side the set drew from, losing the type that differs
500
525
  stored = {key: key for key in kept_expected}
@@ -508,7 +533,11 @@ def _mapping_diff_entries(actual, expected, prefix: _Path, child_seen: set[int],
508
533
  elif key not in actual_keys:
509
534
  entries.append(prefix.key(key).entry(actual=None, absent="actual", expected=kept_expected[key]))
510
535
  else:
511
- decision = _node_decision(kept[key], kept_expected[key], config, field=key)
536
+ decision = (
537
+ _node_decision(*keyed_pair(kept, kept_expected, key), config, field=key)
538
+ if key in keyed
539
+ else _node_decision(kept[key], kept_expected[key], config, field=key)
540
+ )
512
541
  if decision == "leaf":
513
542
  entries.append(prefix.key(key).entry(actual=kept[key], expected=kept_expected[key]))
514
543
  elif decision != "equal":
@@ -596,8 +625,8 @@ def _sub_diff_entries(
596
625
  both_attrs = is_attrs_instance(actual) and is_attrs_instance(expected)
597
626
  if both_model or both_attrs:
598
627
  child_seen = _seen | {id(actual), id(expected)}
599
- actual_dict = _field_dict(actual, both_model)
600
- expected_dict = _field_dict(expected, both_model)
628
+ actual_dict, expected_dict = _field_dict(actual, both_model), _field_dict(expected, both_model)
629
+ keyed = keyed_names(actual_dict, expected_dict)
601
630
  entries = []
602
631
  for key in _ordered_keys(actual_dict, expected_dict):
603
632
  if key not in expected_dict:
@@ -605,7 +634,11 @@ def _sub_diff_entries(
605
634
  elif key not in actual_dict:
606
635
  entries.append(prefix.attr(key).entry(actual=None, absent="actual", expected=expected_dict[key]))
607
636
  else:
608
- decision = _node_decision(actual_dict[key], expected_dict[key], config, field=key)
637
+ decision = (
638
+ _node_decision(*keyed_pair(actual_dict, expected_dict, key), config, field=key)
639
+ if key in keyed
640
+ else _node_decision(actual_dict[key], expected_dict[key], config, field=key)
641
+ )
609
642
  if decision == "leaf":
610
643
  entries.append(prefix.attr(key).entry(actual=actual_dict[key], expected=expected_dict[key]))
611
644
  elif decision != "equal":
@@ -661,9 +694,9 @@ def _walk_leaves(value, prefix: _Path = _ROOT, _seen=None):
661
694
  return
662
695
  if is_model_dump_object(value):
663
696
  child_seen = _seen | {id(value)}
664
- dumped = value.model_dump()
665
- for key in dumped:
666
- yield from _walk_leaves(dumped[key], prefix.attr(str(key), dotted_at_root=False), child_seen)
697
+ held = model_field_values(value)
698
+ for key in held:
699
+ yield from _walk_leaves(held[key], prefix.attr(str(key), dotted_at_root=False), child_seen)
667
700
  return
668
701
  if is_attrs_instance(value):
669
702
  child_seen = _seen | {id(value)}
@@ -32,9 +32,18 @@ from ._compare import (
32
32
  _kinds_never_equal,
33
33
  _node_decision,
34
34
  _spec_matches,
35
+ _types_differ,
35
36
  )
36
37
  from ._diff import _sub_diff_entries
37
- from ._introspection import is_attrs_instance, is_model_dump_object, is_namedtuple
38
+ from ._introspection import (
39
+ TakenApart,
40
+ is_attrs_instance,
41
+ is_model_dump_object,
42
+ is_namedtuple,
43
+ keyed_names,
44
+ keyed_pair,
45
+ model_field_values,
46
+ )
38
47
  from ._path import _ROOT
39
48
  from ._require import refuse
40
49
 
@@ -74,22 +83,21 @@ def comparable_fields(obj: object) -> dict | None:
74
83
 
75
84
  Dataclasses are converted by reference rather than through `dataclasses.asdict`, which deep-copies and
76
85
  crashes on a field that cannot be copied, and a field declared ``compare=False`` is left out, as the
77
- dataclass's own ``==`` leaves it out. An attrs field declared ``eq=False`` is left out the same way.
86
+ dataclass's own ``==`` leaves it out. An attrs field declared ``eq=False`` is left out the same way, and
87
+ one declared with a key, ``eq=str.lower``, is read through it: `attrs.asdict` read the raw value, and
88
+ ``ignore=`` failed on two instances ``==`` holds equal.
78
89
 
79
90
  A value of a builtin kind is not a bag of fields even when it carries a ``__dict__``: a subclass of
80
91
  `Decimal` or `str` has an empty one, and reading it made every two such values compare equal.
81
92
  """
82
93
  if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
83
- return cast("dict", _shallow_fields(obj))
94
+ return cast("dict", _flattened(obj, frozenset({"dataclass"})))
84
95
  if is_namedtuple(obj):
85
- return dict(obj._asdict())
96
+ return TakenApart(type(obj), obj._asdict())
86
97
  if is_model_dump_object(obj):
87
- return obj.model_dump()
98
+ return cast("dict", _flattened(obj, frozenset({"model", "dataclass"})))
88
99
  if is_attrs_instance(obj):
89
- # deferred: at module level it cost 8.5 ms and 22 modules of a 39.8 ms import wherever attrs is installed
90
- import attrs
91
-
92
- return attrs.asdict(obj, filter=lambda attribute, _value: attribute.eq is not False)
100
+ return cast("dict", _flattened(obj, frozenset({"attrs"})))
93
101
  builtin_kinds = (
94
102
  type,
95
103
  numbers.Number,
@@ -104,27 +112,58 @@ def comparable_fields(obj: object) -> dict | None:
104
112
  pathlib.PurePath,
105
113
  )
106
114
  if hasattr(obj, "__dict__") and not isinstance(obj, builtin_kinds):
107
- return dict(vars(obj))
115
+ return TakenApart(type(obj), vars(obj))
108
116
  return None
109
117
 
110
118
 
111
- def _shallow_fields(node: Any) -> Any:
112
- """`dataclasses.asdict` by reference, without the copy, and without the fields ``==`` leaves out."""
113
- if dataclasses.is_dataclass(node) and not isinstance(node, type):
114
- return {
115
- field.name: _shallow_fields(getattr(node, field.name))
116
- for field in dataclasses.fields(node)
117
- if field.compare
118
- }
119
+ def _flattened(node: Any, through: frozenset[str]) -> Any:
120
+ """Fields by reference, as each value's ``==`` reads them, taken apart through the kinds *through* names.
121
+
122
+ A nested value is taken apart where the conversion each kind used to go through took it apart, so a
123
+ nested value is judged as it was: `dataclasses.asdict` went through dataclasses, `attrs.asdict` through
124
+ attrs instances, and `model_dump()` through models and the dataclasses inside them. What is read is
125
+ what is held, though, not what a serialiser or a copy makes of it.
126
+ """
127
+ if "dataclass" in through and dataclasses.is_dataclass(node) and not isinstance(node, type):
128
+ return TakenApart(
129
+ type(node),
130
+ {
131
+ field.name: _flattened(getattr(node, field.name), through)
132
+ for field in dataclasses.fields(node)
133
+ if field.compare
134
+ },
135
+ )
136
+ if "attrs" in through and is_attrs_instance(node):
137
+ compared = [attribute for attribute in node.__attrs_attrs__ if attribute.eq is not False]
138
+ keys = {attribute.name: _key_of(attribute) for attribute in compared}
139
+ return TakenApart(
140
+ type(node),
141
+ {
142
+ attribute.name: getattr(node, attribute.name)
143
+ if keys[attribute.name]
144
+ else _flattened(getattr(node, attribute.name), through)
145
+ for attribute in compared
146
+ },
147
+ {name: key for name, key in keys.items() if key is not None},
148
+ )
149
+ if "model" in through and is_model_dump_object(node):
150
+ return TakenApart(
151
+ type(node), {name: _flattened(value, through) for name, value in model_field_values(node).items()}
152
+ )
119
153
  if isinstance(node, tuple) and hasattr(node, "_fields"):
120
- return type(node)(*[_shallow_fields(item) for item in node])
154
+ return type(node)(*[_flattened(item, through) for item in node])
121
155
  if isinstance(node, (list, tuple)):
122
- return type(node)(_shallow_fields(item) for item in node)
156
+ return type(node)(_flattened(item, through) for item in node)
123
157
  if isinstance(node, dict):
124
- return {_shallow_fields(key): _shallow_fields(value) for key, value in node.items()}
158
+ return {_flattened(key, through): _flattened(value, through) for key, value in node.items()}
125
159
  return node
126
160
 
127
161
 
162
+ def _key_of(attribute: Any) -> Any:
163
+ """The key an attrs field is compared through, given as ``eq=``, or ``None``: its value is then held raw."""
164
+ return getattr(attribute, "eq_key", None)
165
+
166
+
128
167
  def _as_fields(value: object) -> dict | None:
129
168
  """A plain dict as itself, anything else through `comparable_fields`."""
130
169
  return value if isinstance(value, dict) else comparable_fields(value)
@@ -356,15 +395,20 @@ def mapping_differs(
356
395
  if (
357
396
  config is not None
358
397
  and config.strict_types
359
- and _keyed_types_differ(
360
- dict.fromkeys(key for key in left if key in keys_in_actual),
361
- dict.fromkeys(key for key in right if key in keys_in_expected),
398
+ and (
399
+ # two elements taken apart reach here with no parent walk to have compared their classes
400
+ _types_differ(actual, expected)
401
+ or _keyed_types_differ(
402
+ dict.fromkeys(key for key in left if key in keys_in_actual),
403
+ dict.fromkeys(key for key in right if key in keys_in_expected),
404
+ )
362
405
  )
363
406
  ):
364
407
  # `{True: "a"}` and `{1: "a"}` are equal to Python and not under strict types; only the keys still compared
365
408
  return True
409
+ keyed = keyed_names(actual, expected)
366
410
  for key in keys_in_actual:
367
- nested_left, nested_right = left[key], right[key]
411
+ nested_left, nested_right = keyed_pair(left, right, key) if key in keyed else (left[key], right[key])
368
412
  if config is not None:
369
413
  decision = _node_decision(nested_left, nested_right, config, field=key)
370
414
  if decision == "equal":