assertpy2 2.29.0__tar.gz → 2.29.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 (262) hide show
  1. {assertpy2-2.29.0 → assertpy2-2.29.1}/PKG-INFO +1 -1
  2. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_contract.py +398 -50
  3. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/assertpy.py +15 -11
  4. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/concepts/type-safety.md +10 -2
  5. {assertpy2-2.29.0 → assertpy2-2.29.1}/pyproject.toml +1 -1
  6. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_structural.py +573 -0
  7. {assertpy2-2.29.0 → assertpy2-2.29.1}/.gitignore +0 -0
  8. {assertpy2-2.29.0 → assertpy2-2.29.1}/CONTRIBUTING.md +0 -0
  9. {assertpy2-2.29.0 → assertpy2-2.29.1}/LICENSE +0 -0
  10. {assertpy2-2.29.0 → assertpy2-2.29.1}/README.md +0 -0
  11. {assertpy2-2.29.0 → assertpy2-2.29.1}/SECURITY.md +0 -0
  12. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/__init__.py +0 -0
  13. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_clustering.py +0 -0
  14. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_dangling.py +0 -0
  15. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/__init__.py +0 -0
  16. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_builder_check_typing.py +0 -0
  17. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_capable_typing.py +0 -0
  18. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_check_typing.py +0 -0
  19. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_compare.py +0 -0
  20. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_compat.py +0 -0
  21. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_diff.py +0 -0
  22. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_equality.py +0 -0
  23. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_introspection.py +0 -0
  24. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_membership.py +0 -0
  25. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_mixin_base.py +0 -0
  26. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_negated_typing.py +0 -0
  27. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_operations.py +0 -0
  28. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_ordering.py +0 -0
  29. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_pairing.py +0 -0
  30. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_path.py +0 -0
  31. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_poll_typing.py +0 -0
  32. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_require.py +0 -0
  33. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_size.py +0 -0
  34. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_text.py +0 -0
  35. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_typing.py +0 -0
  36. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_hints.py +0 -0
  37. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_inline.py +0 -0
  38. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_matcher_impls.py +0 -0
  39. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_satisfies.py +0 -0
  40. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_snapshot_codec.py +0 -0
  41. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/async_assertions.py +0 -0
  42. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/base.py +0 -0
  43. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/behave_matchers.py +0 -0
  44. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/bytes_mixin.py +0 -0
  45. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/collection.py +0 -0
  46. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/contains.py +0 -0
  47. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/dataframe.py +0 -0
  48. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/date.py +0 -0
  49. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/dict.py +0 -0
  50. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/dynamic.py +0 -0
  51. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/errors.py +0 -0
  52. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/exception.py +0 -0
  53. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/extracting.py +0 -0
  54. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/file.py +0 -0
  55. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/helpers.py +0 -0
  56. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/http_mixin.py +0 -0
  57. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/json_mixin.py +0 -0
  58. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/matchers.py +0 -0
  59. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/numeric.py +0 -0
  60. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/outcome.py +0 -0
  61. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/py.typed +0 -0
  62. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/pytest_plugin.py +0 -0
  63. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/snapshot.py +0 -0
  64. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/string.py +0 -0
  65. {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/warning.py +0 -0
  66. {assertpy2-2.29.0 → assertpy2-2.29.1}/benchmarks/test_perf.py +0 -0
  67. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-equal.png +0 -0
  68. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-equal.svg +0 -0
  69. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-gallery.png +0 -0
  70. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-match.svg +0 -0
  71. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-sequence.svg +0 -0
  72. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-set.svg +0 -0
  73. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-string.svg +0 -0
  74. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/concepts/stability.md +0 -0
  75. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/extending/custom-assertions.md +0 -0
  76. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/extending/integrations.md +0 -0
  77. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/getting-started/comparison.md +0 -0
  78. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/getting-started/migration.md +0 -0
  79. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/getting-started/quickstart.md +0 -0
  80. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/assertions.md +0 -0
  81. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/data.md +0 -0
  82. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/errors.md +0 -0
  83. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/fluent.md +0 -0
  84. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/matchers.md +0 -0
  85. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/testing.md +0 -0
  86. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/index.md +0 -0
  87. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/logo-dark.svg +0 -0
  88. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/logo.svg +0 -0
  89. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/recipes.md +0 -0
  90. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/async.md +0 -0
  91. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/bytes.md +0 -0
  92. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/collections.md +0 -0
  93. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/containment.md +0 -0
  94. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/core.md +0 -0
  95. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/dataframes.md +0 -0
  96. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/dates.md +0 -0
  97. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/dicts.md +0 -0
  98. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/dynamic.md +0 -0
  99. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/entry-points.md +0 -0
  100. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/errors.md +0 -0
  101. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/exceptions.md +0 -0
  102. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/extracting.md +0 -0
  103. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/files.md +0 -0
  104. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/http.md +0 -0
  105. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/json.md +0 -0
  106. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/matchers.md +0 -0
  107. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/numbers.md +0 -0
  108. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/overview.md +0 -0
  109. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/snapshots.md +0 -0
  110. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/strings.md +0 -0
  111. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/warnings.md +0 -0
  112. {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/stylesheets/extra.css +0 -0
  113. {assertpy2-2.29.0 → assertpy2-2.29.1}/mkdocs.yml +0 -0
  114. {assertpy2-2.29.0 → assertpy2-2.29.1}/mkdocs_hooks.py +0 -0
  115. {assertpy2-2.29.0 → assertpy2-2.29.1}/scripts/generate_check_protocols.py +0 -0
  116. {assertpy2-2.29.0 → assertpy2-2.29.1}/scripts/generate_poll_protocols.py +0 -0
  117. {assertpy2-2.29.0 → assertpy2-2.29.1}/scripts/mutation_report.py +0 -0
  118. {assertpy2-2.29.0 → assertpy2-2.29.1}/scripts/regenerate.py +0 -0
  119. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/api_snapshot.json +0 -0
  120. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/api_surface.py +0 -0
  121. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/chain_corpus.py +0 -0
  122. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/chain_model.py +0 -0
  123. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/chain_surfaces.py +0 -0
  124. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/conftest.py +0 -0
  125. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/docs_fixtures.py +0 -0
  126. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/group_compat.py +0 -0
  127. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/poll_parity_baseline.py +0 -0
  128. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/poll_parity_cases.py +0 -0
  129. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/pyright_baseline.py +0 -0
  130. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_api_compatibility.py +0 -0
  131. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_api_vocabulary.py +0 -0
  132. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_architecture_doc.py +0 -0
  133. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_async.py +0 -0
  134. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_async_predicates.py +0 -0
  135. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_attrs_support.py +0 -0
  136. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_behave_matchers.py +0 -0
  137. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_bool.py +0 -0
  138. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_boundary_cases.py +0 -0
  139. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_bytes.py +0 -0
  140. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_call_awaitables.py +0 -0
  141. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_callable.py +0 -0
  142. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_capable_protocol.py +0 -0
  143. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_chain_machine.py +0 -0
  144. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_chaining.py +0 -0
  145. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_check.py +0 -0
  146. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_check_protocols.py +0 -0
  147. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_class.py +0 -0
  148. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_clustering.py +0 -0
  149. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_collection.py +0 -0
  150. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_complexity.py +0 -0
  151. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_core.py +0 -0
  152. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_custom_dict.py +0 -0
  153. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_custom_list.py +0 -0
  154. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dangling.py +0 -0
  155. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dataframe.py +0 -0
  156. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_datetime.py +0 -0
  157. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_declared_floors.py +0 -0
  158. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_deep_nesting.py +0 -0
  159. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_description.py +0 -0
  160. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dict.py +0 -0
  161. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dict_compare.py +0 -0
  162. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_docs_examples.py +0 -0
  163. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_docs_typing.py +0 -0
  164. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_duality.py +0 -0
  165. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dynamic.py +0 -0
  166. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_equality_signals.py +0 -0
  167. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_equals.py +0 -0
  168. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_errors.py +0 -0
  169. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_evaluation_core.py +0 -0
  170. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_exception_context.py +0 -0
  171. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_expected_contract.py +0 -0
  172. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_expected_exception.py +0 -0
  173. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_expected_warning.py +0 -0
  174. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_extensions.py +0 -0
  175. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_extracting.py +0 -0
  176. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_fail.py +0 -0
  177. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_file.py +0 -0
  178. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_grouped_soft.py +0 -0
  179. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_hints.py +0 -0
  180. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_hostile_values.py +0 -0
  181. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_http.py +0 -0
  182. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_in.py +0 -0
  183. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_inline_record.py +0 -0
  184. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_inline_snapshot.py +0 -0
  185. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_integer_arguments.py +0 -0
  186. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_iterable_cluster.py +0 -0
  187. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_json.py +0 -0
  188. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_list.py +0 -0
  189. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_match_result.py +0 -0
  190. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_matcher_algebra.py +0 -0
  191. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_matcher_parity.py +0 -0
  192. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_matcher_registry.py +0 -0
  193. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_matchers.py +0 -0
  194. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_message_elision.py +0 -0
  195. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_namedtuple.py +0 -0
  196. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_negated_protocols.py +0 -0
  197. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_none.py +0 -0
  198. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_not.py +0 -0
  199. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_number_zoo.py +0 -0
  200. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_numbers.py +0 -0
  201. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_numeric_questions.py +0 -0
  202. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_openapi_contract.py +0 -0
  203. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_operation_contract.py +0 -0
  204. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_optional_integration_contracts.py +0 -0
  205. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_overload_order.py +0 -0
  206. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_overloads.py +0 -0
  207. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_pin_coverage.py +0 -0
  208. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_pipeline.py +0 -0
  209. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_poll_parity.py +0 -0
  210. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_poll_protocols.py +0 -0
  211. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_prerequisites.py +0 -0
  212. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_property_attrs.py +0 -0
  213. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_property_based.py +0 -0
  214. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_protocol_parity.py +0 -0
  215. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_public_surface.py +0 -0
  216. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_pyright_baseline.py +0 -0
  217. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_pytest_plugin.py +0 -0
  218. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_assertion.py +0 -0
  219. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_compare.py +0 -0
  220. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_compare_attrs.py +0 -0
  221. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_compare_config.py +0 -0
  222. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_compare_pydantic.py +0 -0
  223. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_refusals.py +0 -0
  224. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_regex_groups.py +0 -0
  225. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_requirement.py +0 -0
  226. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_rich_diff.py +0 -0
  227. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_same_as.py +0 -0
  228. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_satisfy.py +0 -0
  229. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_skip_guard.py +0 -0
  230. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_snapshots.py +0 -0
  231. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_soft.py +0 -0
  232. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_soft_fail.py +0 -0
  233. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_string.py +0 -0
  234. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_surface_conformance.py +0 -0
  235. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_sync_eventually.py +0 -0
  236. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_traceback.py +0 -0
  237. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_type.py +0 -0
  238. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_type_expression_failures.py +0 -0
  239. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing.py +0 -0
  240. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_chain_corpus.py +0 -0
  241. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_claims.py +0 -0
  242. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_completeness.py +0 -0
  243. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_conformance.py +0 -0
  244. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_from_a_wheel.py +0 -0
  245. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_http.py +0 -0
  246. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_integrations.py +0 -0
  247. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_negative.py +0 -0
  248. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_promises.py +0 -0
  249. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_unrenderable_values.py +0 -0
  250. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_vacuity_contract.py +0 -0
  251. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_verdict_corpus.py +0 -0
  252. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_warn.py +0 -0
  253. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_cases.py +0 -0
  254. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_chain_corpus_baseline.json +0 -0
  255. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_harness.py +0 -0
  256. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_http.py +0 -0
  257. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_integrations.py +0 -0
  258. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_integrations_baseline.py +0 -0
  259. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_negative_baseline.py +0 -0
  260. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_promises.py +0 -0
  261. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/verdict_corpus.py +0 -0
  262. {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/verdict_golden.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: assertpy2
3
- Version: 2.29.0
3
+ Version: 2.29.1
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
@@ -12,8 +12,10 @@ import datetime
12
12
  import decimal
13
13
  import enum
14
14
  import fractions
15
+ import inspect
15
16
  import json
16
17
  import pathlib
18
+ import sys
17
19
  import types
18
20
  import uuid
19
21
  from functools import reduce
@@ -115,10 +117,166 @@ def _may_hold_model(annotation: object, outer: bool = True) -> bool:
115
117
  return origin is not None or not isinstance(annotation, type) or not issubclass(annotation, _KEYLESS)
116
118
 
117
119
 
118
- _FieldReads = tuple[tuple[str, tuple[tuple[object, ...], ...]], ...]
120
+ def _alternatives(annotation: object) -> tuple[object, ...]:
121
+ """The members of a union, `Annotated` and nested unions opened, or the annotation alone."""
122
+ while get_origin(annotation) is Annotated:
123
+ annotation = get_args(annotation)[0]
124
+ if get_origin(annotation) in (Union, types.UnionType):
125
+ return tuple(member for argument in get_args(annotation) for member in _alternatives(argument))
126
+ return (annotation,)
127
+
128
+
129
+ _ITEM_ORIGINS = frozenset(
130
+ {
131
+ list,
132
+ tuple,
133
+ set,
134
+ frozenset,
135
+ collections.deque,
136
+ collections.abc.Sequence,
137
+ collections.abc.MutableSequence,
138
+ collections.abc.Set,
139
+ collections.abc.MutableSet,
140
+ collections.abc.Collection,
141
+ collections.abc.Iterable,
142
+ }
143
+ )
144
+
145
+
146
+ _MAPPING_ORIGINS = frozenset({dict, collections.abc.Mapping, collections.abc.MutableMapping})
147
+
148
+ _Declared = tuple[object, object]
149
+ """A declared type and the model class whose field declared it, whose config its validation ran in."""
150
+
151
+ _DECLARED: dict[tuple[int, object], tuple[_Declared, Any]] = {}
152
+ """What a declaration says a container built from it holds, per declaration and kind of container: worked out once,
153
+ since a list of 1000 orders asks it for each order. At most 1024 entries (`_remember`)."""
154
+
155
+
156
+ def _remember(
157
+ cache: dict[Any, tuple[_Declared, Any]], key: object, declared: _Declared, answer: object, bound: int
158
+ ) -> None:
159
+ """Keep *answer* for *declared* under *key*, beside the declaration so the id stays its own. A full *cache* starts
160
+ over rather than refusing new entries: a long run fills it early, and what it asks now is what it asks again."""
161
+ if len(cache) >= bound:
162
+ cache.clear()
163
+ cache[key] = (declared, answer)
164
+
165
+
166
+ def _declared_container(declared: _Declared | None) -> tuple[tuple[_Declared, ...] | None, _Declared | None] | None:
167
+ """What *declared* says the items of a container built from it are declared as: one declaration per position of a
168
+ fixed tuple, or one for every item; ``None`` where it does not say.
169
+
170
+ Only a declaration that leaves validation no choice says (`_sole_member`): which member of a union validation took
171
+ depends on what the field's own validators made of the payload, and the kind of container they left does not tell.
172
+ """
173
+ if declared is None:
174
+ return None
175
+ key = (id(declared), "items")
176
+ known = _DECLARED.get(key)
177
+ if known is not None and known[0] is declared:
178
+ return known[1]
179
+ annotation, owner = declared
180
+ container = _sole_member(annotation)
181
+ items = None
182
+ if get_origin(container) in _ITEM_ORIGINS and get_args(container):
183
+ arguments = get_args(container)
184
+ if _is_fixed_tuple(container):
185
+ items = (tuple((argument, owner) for argument in arguments), None)
186
+ else:
187
+ items = (None, (arguments[0], owner))
188
+ _remember(_DECLARED, key, declared, items, 1024)
189
+ return items
190
+
191
+
192
+ def _declared_mapping_value(declared: _Declared | None) -> _Declared | None:
193
+ """The declaration of the values of the mapping *declared* is, where it leaves validation no choice, or ``None``."""
194
+ if declared is None:
195
+ return None
196
+ key = (id(declared), "values")
197
+ known = _DECLARED.get(key)
198
+ if known is not None and known[0] is declared:
199
+ return known[1]
200
+ annotation, owner = declared
201
+ mapping = _sole_member(annotation)
202
+ value = (
203
+ (get_args(mapping)[1], owner)
204
+ if get_origin(mapping) in _MAPPING_ORIGINS and len(get_args(mapping)) == 2
205
+ else None
206
+ )
207
+ _remember(_DECLARED, key, declared, value, 1024)
208
+ return value
209
+
210
+
211
+ def _sole_member(annotation: object) -> object:
212
+ """*annotation* without `Annotated` and without ``None`` beside it; ``None`` where a union leaves a choice."""
213
+ members = [member for member in _alternatives(annotation) if member is not type(None)]
214
+ return members[0] if len(members) == 1 else None
215
+
216
+
217
+ def _chooses(annotation: object) -> bool:
218
+ """Whether validation chose anywhere in *annotation* above the models it names: between two or more members of a
219
+ union besides ``None``, or inside a named type alias (`typing` or `typing_extensions`, plain or parametrized),
220
+ which is not opened."""
221
+ members = [member for member in _alternatives(annotation) if member is not type(None)]
222
+ aliases = tuple(
223
+ kind
224
+ for module in ("typing", "typing_extensions")
225
+ if isinstance(kind := getattr(sys.modules.get(module), "TypeAliasType", None), type)
226
+ )
227
+ if len(members) > 1 or any(
228
+ isinstance(named, aliases) for member in members for named in (member, get_origin(member))
229
+ ):
230
+ return True
231
+ return any(_chooses(argument) for member in members for argument in get_args(member) if argument is not Ellipsis)
232
+
119
233
 
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.
234
+ def _is_fixed_tuple(container: object) -> bool:
235
+ arguments = get_args(container)
236
+ return get_origin(container) is tuple and not (len(arguments) == 2 and arguments[1] is Ellipsis)
237
+
238
+
239
+ def _item_declarations(declared: _Declared | None, count: int) -> list[_Declared | None]:
240
+ """The declaration of each of *count* items of a container built from *declared*, by position where a fixed tuple
241
+ declares one."""
242
+ items = _declared_container(declared)
243
+ if items is None:
244
+ return [None] * count
245
+ positional, common = items
246
+ if positional is None:
247
+ return [common] * count
248
+ return [positional[index] if index < len(positional) else None for index in range(count)]
249
+
250
+
251
+ _ADAPTERS: dict[int, tuple[_Declared, Any]] = {}
252
+ """A pydantic `TypeAdapter` per declaration, or ``None`` where none can be built: building one costs a schema. At
253
+ most 256 entries (`_remember`)."""
254
+
255
+
256
+ def _adapter(declared: _Declared) -> Any:
257
+ """A `TypeAdapter` for a declared type, in the config of the model that declared it, from the pydantic the models
258
+ came from; ``None`` where there is none."""
259
+ known = _ADAPTERS.get(id(declared))
260
+ if known is not None and known[0] is declared:
261
+ return known[1]
262
+ annotation, owner = declared
263
+ adapter_class = getattr(sys.modules.get("pydantic"), "TypeAdapter", None)
264
+ adapter = None
265
+ if adapter_class is not None:
266
+ # a schema pydantic refuses: TypeError in 2.0, RuntimeError in 2.13, NameError for a forward reference
267
+ try:
268
+ adapter = adapter_class(annotation, config=getattr(owner, "model_config", None) or None)
269
+ except (TypeError, RuntimeError, NameError):
270
+ adapter = None
271
+ _remember(_ADAPTERS, id(declared), declared, adapter, 256)
272
+ return adapter
273
+
274
+
275
+ _FieldReads = tuple[tuple[str, tuple[tuple[object, ...], ...], _Declared], ...]
276
+
277
+ _READS: dict[type, tuple[object, frozenset[str], _FieldReads, _Declared | None]] = {}
278
+ """Per model class, with the validator they were read beside: the keys it declares, where each field is read, and
279
+ what a typed `__pydantic_extra__` declares its extra values as.
122
280
 
123
281
  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
282
  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.
@@ -126,20 +284,39 @@ A rebuild replaces the validator, and with it what the class reads, so an entry
126
284
  was made with. At most 256 classes are kept, so models made on the fly cannot grow it without bound."""
127
285
 
128
286
 
129
- def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads]:
287
+ def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads, _Declared | None]:
130
288
  validator = getattr(model, "__pydantic_validator__", None)
131
289
  known = _READS.get(model)
132
290
  if known is None or known[0] is not validator:
133
291
  config = getattr(model, "model_config", {})
134
292
  fields = tuple(
135
- (name, _field_sources(name, info, config))
293
+ (name, _field_sources(name, info, config), (getattr(info, "annotation", None), model))
136
294
  for name, info in model.model_fields.items()
137
295
  if _may_hold_model(getattr(info, "annotation", None))
138
296
  )
139
- known = (validator, frozenset(_declared_keys(model)), fields)
297
+ extras = _extra_values(model) if config.get("extra") == "allow" else None
298
+ known = (validator, frozenset(_declared_keys(model)), fields, extras)
140
299
  if model in _READS or len(_READS) < 256:
141
300
  _READS[model] = known
142
- return known[1], known[2]
301
+ return known[1], known[2], known[3]
302
+
303
+
304
+ def _extra_values(model: Any) -> _Declared | None:
305
+ """The declaration a typed `__pydantic_extra__` gives the extra values of *model*, as written.
306
+
307
+ ``None`` where it has none, and where it cannot be read: text, which pydantic read in the namespace of the frame
308
+ that defined the model, where a name can mean another type than in its module, and, from 3.14, annotations that
309
+ name what is defined only later, which reading them evaluates.
310
+ """
311
+ try:
312
+ for klass in getattr(model, "__mro__", ()):
313
+ own = inspect.get_annotations(klass)
314
+ if "__pydantic_extra__" in own:
315
+ annotation = own["__pydantic_extra__"]
316
+ return None if isinstance(annotation, str) else _declared_mapping_value((annotation, model))
317
+ except NameError:
318
+ return None
319
+ return None
143
320
 
144
321
 
145
322
  def _followed(payload: object, path: tuple[object, ...]) -> tuple[bool, object]:
@@ -176,6 +353,10 @@ def _is_model(value: object) -> bool:
176
353
 
177
354
  _CONTAINERS = (dict, list, tuple, set, frozenset)
178
355
 
356
+ _REPLAYED = (0, -1)
357
+ """In the pairs a walk has seen, where no pair of ids can be: the walk is under a replay (`_replayed`), where a field
358
+ whose type offers a choice cannot be read (`_steered`)."""
359
+
179
360
 
180
361
  class UncheckableDriftError(Exception):
181
362
  """A part of the payload no reading pairs with the model it became, so whether it drifted cannot be told."""
@@ -201,41 +382,84 @@ def contract_drift(
201
382
  inside the JSON text it is) cannot be paired with a model built from it: a set, a resized list or an
202
383
  object wrapped into either whose built items are not all one model class, or whose raw item that class
203
384
  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.
385
+ validation read up or left lazy, raw items no built item is left for that their declared type does not
386
+ build again or that it declares through a choice. The payload is read as validation left it: a validator
387
+ renaming keys, changing the payload in place, or reordering or rewriting a container's items without
388
+ changing its size is not seen, and the walk reads those items by position.
207
389
  """
208
390
  model = type(instance)
391
+ declared, fields, extras = _reads_of(model)
392
+ replayed = _REPLAYED in _seen
209
393
  if getattr(model, "__pydantic_root_model__", False):
210
- return _value_drift(payload, instance.root, path, _seen) if _reads_of(model)[1] else []
394
+ return _declared_drift(payload, instance.root, path, _seen, fields[0][2], replayed) if fields else []
211
395
  if not isinstance(payload, (dict, collections.abc.Mapping)):
212
396
  _refuse_if_key_hides(
213
397
  payload, instance, path, f"the payload holds a {type(payload).__name__} where a model was built"
214
398
  )
215
399
  return []
216
400
  seen = _seen | {(id(payload), id(instance))}
217
- declared, fields = _reads_of(model)
218
401
  prefix = f"{path}." if path else ""
219
402
  drift: list[str] = []
220
403
  if getattr(model, "model_config", {}).get("extra") != "allow":
221
404
  drift += [f"{prefix}{key}" for key in payload if key not in declared]
222
405
  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:
406
+ drift += _extras_drift(payload, instance, prefix, seen, extras, replayed)
407
+ for name, sources, annotated in fields:
228
408
  found, raw = _raw_field(payload, sources)
229
409
  if not found:
230
410
  continue
411
+ if replayed and _chooses(annotated[0]):
412
+ drift += _steered(raw, f"{prefix}{name}")
413
+ continue
231
414
  value = getattr(instance, name, None)
232
415
  # a scalar built from a scalar holds nothing to pair
233
416
  if (value is None or isinstance(value, (str, int, float, bytes))) and not isinstance(raw, _CONTAINERS):
234
417
  continue
235
- drift += _value_drift(raw, value, f"{prefix}{name}", seen)
418
+ drift += _value_drift(raw, value, f"{prefix}{name}", seen, annotated)
236
419
  return drift
237
420
 
238
421
 
422
+ def _extras_drift(
423
+ payload: collections.abc.Mapping,
424
+ instance: Any,
425
+ prefix: str,
426
+ seen: frozenset[tuple[int, int]],
427
+ declared: _Declared | None,
428
+ replayed: bool,
429
+ ) -> list[str]:
430
+ """Drift under the extras of a model that allows them: built through a typed `__pydantic_extra__` and walked as it
431
+ *declared* them; untyped ones are the payload's own objects."""
432
+ return [
433
+ entry
434
+ for key, built in (getattr(instance, "__pydantic_extra__", None) or {}).items()
435
+ if key in payload
436
+ for entry in _declared_drift(payload[key], built, f"{prefix}{key}", seen, declared, replayed)
437
+ ]
438
+
439
+
440
+ def _declared_drift(
441
+ raw: object,
442
+ value: object,
443
+ path: str,
444
+ seen: frozenset[tuple[int, int]],
445
+ declared: _Declared | None,
446
+ replayed: bool,
447
+ ) -> list[str]:
448
+ """Drift under a value its model declared, a root's or an extra's; `_steered` under a replay where validation built
449
+ it through a declared type that offers a choice, or one that cannot be read."""
450
+ if replayed and value is not raw and (declared is None or _chooses(declared[0])):
451
+ return _steered(raw, path)
452
+ return _value_drift(raw, value, path, seen, declared)
453
+
454
+
455
+ def _steered(raw: object, path: str) -> list[str]:
456
+ """Nothing to read under a part a replay built where its type offered a choice, which the validators the replay
457
+ skipped could have steered; a refusal where a key could hide in it."""
458
+ if _holds(raw, _is_mapping, read_text=True):
459
+ raise UncheckableDriftError(path, "which of its declared types it became depends on validators not run again")
460
+ return []
461
+
462
+
239
463
  def exactness_failure(pairs: Any, *, carrier: str) -> str | None:
240
464
  """What an exact check of ``(payload, instance, path)`` pairs found, in the words a failure continues with.
241
465
 
@@ -254,8 +478,14 @@ def exactness_failure(pairs: Any, *, carrier: str) -> str | None:
254
478
  return f"{carrier}{len(drift)} undeclared field(s) the model does not declare: {sorted(drift)}"
255
479
 
256
480
 
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."""
481
+ def _value_drift(
482
+ raw: object, value: object, path: str, seen: frozenset[tuple[int, int]], declared: _Declared | None = None
483
+ ) -> list[str]:
484
+ """Drift under one validated value: a model's own keys, and every element or dict value that became a model.
485
+
486
+ *declared* is the type the value was declared as and the model that declared it, where the walk knows them: what
487
+ a model field or a root declares, and from it the item type of each container below.
488
+ """
259
489
  # the payload's own object kept as it was, or a scalar: nothing was built from it
260
490
  if raw is value or value is None or isinstance(value, (str, int, float, bytes)):
261
491
  return []
@@ -268,48 +498,68 @@ def _value_drift(raw: object, value: object, path: str, seen: frozenset[tuple[in
268
498
  seen = seen | {pair}
269
499
  if _is_model(value):
270
500
  return contract_drift(raw, value, path, seen)
271
- return _container_drift(raw, value, path, seen)
501
+ return _container_drift(raw, value, path, seen, declared)
272
502
 
273
503
 
274
- def _container_drift(raw: object, value: object, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
504
+ def _container_drift(
505
+ raw: object, value: object, path: str, seen: frozenset[tuple[int, int]], declared: _Declared | None
506
+ ) -> list[str]:
275
507
  """Drift under a built container, paired with the raw part it came from as its kind allows."""
276
508
  if isinstance(value, (list, tuple)):
277
- return _sequence_drift(raw, value, path, seen)
509
+ return _sequence_drift(raw, value, path, seen, declared)
278
510
  if isinstance(value, dict):
279
- return _mapping_drift(raw, value, path, seen)
511
+ return _mapping_drift(raw, value, path, seen, declared)
280
512
  if isinstance(value, (set, frozenset)):
281
- return _set_drift(raw, value, path, seen)
513
+ return _set_drift(raw, value, path, seen, declared)
282
514
  if isinstance(value, collections.abc.Mapping):
283
- return _mapping_drift(raw, dict(value), path, seen)
515
+ return _mapping_drift(raw, dict(value), path, seen, declared)
284
516
  if isinstance(value, collections.abc.Iterator):
285
517
  if _holds(raw, _is_mapping, read_text=True):
286
518
  raise UncheckableDriftError(path, "validation is lazy here and builds the models only as the value is read")
287
519
  return []
288
520
  # a sequence validation kept as the payload's own kind, a deque under `Sequence[A]`
289
521
  items = _items_of(value)
290
- return [] if items is None else _sequence_drift(raw, items, path, seen)
522
+ return [] if items is None else _sequence_drift(raw, items, path, seen, declared)
291
523
 
292
524
 
293
- def _sequence_drift(raw: object, value: list | tuple, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
525
+ def _sequence_drift(
526
+ raw: object,
527
+ value: list | tuple,
528
+ path: str,
529
+ seen: frozenset[tuple[int, int]],
530
+ declared: _Declared | None = None,
531
+ ) -> list[str]:
294
532
  """Paired by index, a collection pydantic also takes for a sequence (a deque, a set, a dict's values) in the
295
533
  order it iterates, which is the order validation read it in."""
296
534
  if not isinstance(raw, (list, tuple)):
297
535
  items = _items_of(raw)
298
536
  if items is None:
299
537
  reason = f"the payload holds a {type(raw).__name__} where a sequence was built"
300
- return _wrapped(raw, value, path, seen, reason)
538
+ return _wrapped(raw, value, path, seen, reason, declared)
301
539
  raw = items
302
540
  if len(raw) != len(value):
303
541
  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)
542
+ parts = [(f"{path}[{i}]", part) for i, part in enumerate(raw)]
543
+ return _unpaired(raw, parts, value, path, seen, reason, declared=declared)
544
+ items = _declared_container(declared)
545
+ if items is None or items[0] is None:
546
+ common = None if items is None else items[1]
547
+ return [
548
+ entry
549
+ for index, (part, element) in enumerate(zip(raw, value, strict=True))
550
+ for entry in _value_drift(part, element, f"{path}[{index}]", seen, common)
551
+ ]
552
+ declarations = _item_declarations(declared, len(value))
305
553
  return [
306
554
  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)
555
+ for index, (part, element, item) in enumerate(zip(raw, value, declarations, strict=True))
556
+ for entry in _value_drift(part, element, f"{path}[{index}]", seen, item)
309
557
  ]
310
558
 
311
559
 
312
- def _mapping_drift(raw: object, value: dict, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
560
+ def _mapping_drift(
561
+ raw: object, value: dict, path: str, seen: frozenset[tuple[int, int]], declared: _Declared | None = None
562
+ ) -> list[str]:
313
563
  """Paired by key where validation kept the keys (a `TypedDict` reorders them), else by order, which dict validation
314
564
  keeps while it coerces them; a key built into something other than text or a number is walked beside its raw key."""
315
565
  if not isinstance(raw, (dict, collections.abc.Mapping)):
@@ -319,38 +569,48 @@ def _mapping_drift(raw: object, value: dict, path: str, seen: frozenset[tuple[in
319
569
  # an overwritten value may have become another member of a union than the one that survived
320
570
  _refuse_if_key_hides(raw, value, path, f"validation changed its size, {len(raw)} keys became {len(value)}")
321
571
  return []
572
+ item = _declared_mapping_value(declared)
322
573
  if raw.keys() == value.keys():
323
574
  return [
324
575
  entry
325
576
  for key, part in raw.items()
326
- for entry in _value_drift(part, value[key], f"{path}.{key}" if path else str(key), seen)
577
+ for entry in _value_drift(part, value[key], f"{path}.{key}" if path else str(key), seen, item)
327
578
  ]
328
579
  drift: list[str] = []
329
580
  for (key, part), (built, element) in zip(raw.items(), value.items(), strict=True):
330
581
  if not isinstance(built, (str, int)):
331
582
  drift += _value_drift(key, built, path, seen)
332
- drift += _value_drift(part, element, f"{path}.{key}" if path else str(key), seen)
583
+ drift += _value_drift(part, element, f"{path}.{key}" if path else str(key), seen, item)
333
584
  return drift
334
585
 
335
586
 
336
- def _set_drift(raw: object, value: set | frozenset, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
587
+ def _set_drift(
588
+ raw: object, value: set | frozenset, path: str, seen: frozenset[tuple[int, int]], declared: _Declared | None
589
+ ) -> list[str]:
337
590
  items = raw if isinstance(raw, (list, tuple)) else _items_of(raw)
338
591
  if items is None:
339
592
  reason = f"the payload holds a {type(raw).__name__} where a set was built"
340
- return _wrapped(raw, value, path, seen, reason)
593
+ return _wrapped(raw, value, path, seen, reason, declared)
341
594
  reason = "a set keeps no order to pair its items with the models they became"
342
595
  parts = [(f"{path}[{i}]", part) for i, part in enumerate(items)]
343
- return _unpaired(raw, parts, value, path, seen, reason, merged=True)
596
+ return _unpaired(raw, parts, value, path, seen, reason, merged=True, declared=declared)
344
597
 
345
598
 
346
- def _wrapped(raw: object, value: object, path: str, seen: frozenset[tuple[int, int]], reason: str) -> list[str]:
599
+ def _wrapped(
600
+ raw: object,
601
+ value: object,
602
+ path: str,
603
+ seen: frozenset[tuple[int, int]],
604
+ reason: str,
605
+ declared: _Declared | None,
606
+ ) -> list[str]:
347
607
  """Drift under a mapping a validator turned into a sequence or a set, a single object sent where many may be.
348
608
 
349
- Only a mapping that became one item is that item; one that became several was expanded in a way no reading
350
- pairs.
609
+ Only a mapping that became one item, or none, is that item; one that became several was expanded in a way no
610
+ reading pairs.
351
611
  """
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)
612
+ if isinstance(raw, (dict, collections.abc.Mapping)) and len(_items_of(value) or ()) <= 1:
613
+ return _unpaired(raw, [(path, raw)], value, path, seen, reason, declared=declared)
354
614
  _refuse_if_key_hides(raw, value, path, reason)
355
615
  return []
356
616
 
@@ -364,21 +624,109 @@ def _unpaired(
364
624
  reason: str,
365
625
  *,
366
626
  merged: bool = False,
627
+ declared: _Declared | None = None,
367
628
  ) -> list[str]:
368
629
  """Drift under a container whose raw items no reading pairs one by one with the items validation built.
369
630
 
370
631
  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.
632
+ could hide a key is validated again on its own by that class and walked beside what it becomes. Where validation
633
+ kept no model at all, the raw items are validated again whole by the type the field *declared*, in its model's
634
+ config, and what that builds is walked (`_replayed`). Anything else refuses with *reason*: items of mixed classes,
635
+ an item that does not validate on its own, which needed its parent, where equal items *merged*, a class whose own
636
+ `__eq__` could have merged an item of another class into it, and raw items no replay can tell the type of.
375
637
  """
376
- if not _holds(value, _builds_model) or not _holds(raw, _is_mapping, read_text=True):
638
+ if not _holds(raw, _is_mapping, read_text=True):
377
639
  return []
378
- model = _sole_model_class(value)
640
+ if _holds(value, _builds_model):
641
+ model = _sole_model_class(value)
642
+ if model is None or (merged and not _equal_only_within_its_class(model)):
643
+ raise UncheckableDriftError(path, reason)
644
+ return _revalidated(parts, model, path, seen, reason)
645
+ return _replayed(parts, path, seen, reason, declared)
646
+
647
+
648
+ def _replayed(
649
+ parts: list[tuple[str, object]],
650
+ path: str,
651
+ seen: frozenset[tuple[int, int]],
652
+ reason: str,
653
+ declared: _Declared | None,
654
+ ) -> list[str]:
655
+ """Drift under raw items validation left without a model: validated again whole as the *declared* type, in Python
656
+ mode as validation read them (`_replay_target`). All of them are replayed first, so a fixed tuple's positions
657
+ see what validation saw; where pydantic refuses that, only the items that could hide a key. What that builds is
658
+ paired item by item; a set it builds tells the one class they became.
659
+
660
+ The replay skips the validators of the field itself, so whatever they did to the payload is missing from it, and
661
+ it refuses with *reason* wherever that leaves the type of the items open: a declaration that offers a choice
662
+ (`_chooses`) or none at all, items pydantic builds nothing from, or no sequence of as many.
663
+ """
664
+ if declared is None or _chooses(declared[0]):
665
+ raise UncheckableDriftError(path, reason)
666
+ replayed = _replay_target(declared)
667
+ adapter = _adapter(replayed)
668
+ if adapter is None:
669
+ raise UncheckableDriftError(path, reason)
670
+ origin = get_origin(replayed[0])
671
+ kind = origin if origin in (tuple, collections.deque) else list
672
+ rebuilt = _replay(adapter, parts, kind)
673
+ if rebuilt is None:
674
+ parts = [(label, part) for label, part in parts if _holds(part, _is_mapping, read_text=True)]
675
+ rebuilt = _replay(adapter, parts, kind)
676
+ seen = seen | {_REPLAYED}
677
+ if isinstance(rebuilt, collections.abc.Set):
678
+ return _unpaired([part for _, part in parts], parts, rebuilt, path, seen, reason, merged=True)
679
+ elements = _items_of(rebuilt)
680
+ if elements is None or len(elements) != len(parts):
681
+ raise UncheckableDriftError(path, reason)
682
+ declarations = _item_declarations(replayed, len(elements))
683
+ return [
684
+ entry
685
+ for (label, part), element, item in zip(parts, elements, declarations, strict=True)
686
+ for entry in _value_drift(part, element, label, seen, item)
687
+ ]
688
+
689
+
690
+ def _replay(adapter: Any, parts: list[tuple[str, object]], kind: Callable[[list[object]], object]) -> object:
691
+ """What *adapter* builds from the raw items of *parts*, handed over as the *kind* of container a strict config
692
+ takes for its type, or ``None`` where pydantic refuses them."""
693
+ try:
694
+ return adapter.validate_python(kind([part for _, part in parts]))
695
+ # a validation error, or a schema pydantic builds only on first use and then refuses
696
+ except (ValueError, TypeError, RuntimeError, NameError):
697
+ return None
698
+
699
+
700
+ def _replay_target(declared: _Declared) -> _Declared:
701
+ """The type raw items are validated again as: the one member of the *declared* type without the `Annotated`
702
+ validators of the container itself, which are what emptied it, and a set as a list of its items, so no model is
703
+ hashed and each item keeps its place."""
704
+ key = (id(declared), "replay")
705
+ known = _DECLARED.get(key)
706
+ if known is not None and known[0] is declared:
707
+ return known[1]
708
+ annotation, owner = declared
709
+ member = _sole_member(annotation) or annotation
710
+ if member in (set, frozenset) or get_origin(member) in (
711
+ set,
712
+ frozenset,
713
+ collections.abc.Set,
714
+ collections.abc.MutableSet,
715
+ ):
716
+ member = types.GenericAlias(list, get_args(member) or (Any,))
717
+ target = (member, owner)
718
+ _remember(_DECLARED, key, declared, target, 1024)
719
+ return target
720
+
721
+
722
+ def _revalidated(
723
+ parts: list[tuple[str, object]], model: type, path: str, seen: frozenset[tuple[int, int]], reason: str
724
+ ) -> list[str]:
725
+ """Drift under each raw item that could hide a key, validated again on its own by *model*, JSON text in JSON mode
726
+ as `Json[...]` read it."""
379
727
  validate = getattr(model, "model_validate", None)
380
728
  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)):
729
+ if validate is None or validate_json is None:
382
730
  raise UncheckableDriftError(path, reason)
383
731
  drift: list[str] = []
384
732
  for label, item in parts:
@@ -97,7 +97,7 @@ from .snapshot import SnapshotMixin
97
97
  from .string import StringMixin
98
98
  from .warning import WarningMixin
99
99
 
100
- __version__ = "2.29.0"
100
+ __version__ = "2.29.1"
101
101
 
102
102
  # the tracked value type of the generic AssertionBuilder fallback (_U appears only in narrowing stubs)
103
103
  _T = TypeVar("_T")
@@ -701,17 +701,21 @@ def assert_conforms(
701
701
  does not declare. ``model_validate`` silently drops undeclared fields, so a stale model keeps
702
702
  passing after the live API grows new ones.
703
703
 
704
- ``exact`` catches that drift in every model the payload became, inside lists, tuples, dicts, unions
705
- and root models too, and reports the exact paths. It is alias-aware, and respects a model that opts
706
- into extras (``extra="allow"``). Raw items that no longer pair one by one with what they became (a set,
707
- a filtered list, an object wrapped into a list) are validated again by their model class when they all
704
+ ``exact`` catches that drift in the models the payload became inside lists, tuples, sets, dicts, unions
705
+ and root models, and reports the exact paths. It is alias-aware, and respects a model that opts into
706
+ extras (``extra="allow"``). Raw items that no longer pair one by one with what they became (a set, a
707
+ filtered list, an object wrapped into a list) are validated again by their model class when they all
708
708
  became one, which runs that class's validators once more for them, and JSON text is read as the original
709
- JSON input. What still cannot be paired (items of mixed classes in a set or a resized list, merged dict
710
- keys, a generator consumed during validation, a lazy ``Iterable``) fails with ``<path> cannot be
711
- checked`` and the reason. It reads the payload as validation left it: a validator that renames keys,
712
- changes the payload in place, or reorders or rewrites the items of a container without changing its size
713
- is not seen, and those items are read by position. A model inside a container of plain values
714
- (``list[Any]``) or inside a dataclass is not reached.
709
+ JSON input. Raw items a validator left no built item for (an emptied list) are checked against the type
710
+ their field declares: validated again as it, which runs the item model's validators but not the field's
711
+ own, so a field validator that would have built them otherwise is not seen. What still cannot be paired
712
+ (items of mixed classes in a set or a resized list, emptied items the declared type does not build or
713
+ declares through a union of two or more types besides ``None`` or a named type alias, merged dict keys, a
714
+ generator consumed during validation, a lazy ``Iterable``) fails with ``<path> cannot be checked`` and the
715
+ reason. It reads the payload as validation left it: a validator that renames keys, changes the payload in
716
+ place, or reorders or rewrites the items of a container without changing its size is not seen, and those
717
+ items are read by position. A model inside a container of plain values (``list[Any]``) or inside a
718
+ dataclass is not reached.
715
719
 
716
720
  Args:
717
721
  val: the raw payload to validate (e.g. a decoded JSON response)