assertpy2 2.17.0__tar.gz → 2.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/workflows/ci.yml +2 -2
  2. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/workflows/codeql.yml +2 -2
  3. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/workflows/codspeed.yml +1 -1
  4. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/workflows/docs.yml +1 -1
  5. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/workflows/mutation.yml +1 -1
  6. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/workflows/publish.yml +1 -1
  7. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/workflows/scorecard.yml +1 -1
  8. {assertpy2-2.17.0 → assertpy2-2.18.0}/PKG-INFO +2 -1
  9. {assertpy2-2.17.0 → assertpy2-2.18.0}/README.md +1 -0
  10. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/__init__.py +2 -1
  11. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_engine/_contract.py +10 -5
  12. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_engine/_mixin_base.py +1 -0
  13. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_engine/_typing.py +13 -9
  14. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_matcher_impls.py +12 -0
  15. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_satisfies.py +78 -14
  16. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_snapshot_codec.py +16 -8
  17. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/assertpy.py +65 -20
  18. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/async_assertions.py +23 -0
  19. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/collection.py +23 -10
  20. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/contains.py +39 -4
  21. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/date.py +9 -2
  22. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/errors.py +10 -0
  23. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/extracting.py +14 -4
  24. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/file.py +3 -1
  25. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/pytest_plugin.py +86 -1
  26. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/snapshot.py +43 -9
  27. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/getting-started/comparison.md +32 -21
  28. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/getting-started/migration.md +18 -14
  29. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/guides/assertions.md +67 -4
  30. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/guides/errors.md +90 -40
  31. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/guides/testing.md +135 -69
  32. {assertpy2-2.17.0 → assertpy2-2.18.0}/pyproject.toml +2 -2
  33. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_async.py +46 -1
  34. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_collection.py +33 -0
  35. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_datetime.py +31 -0
  36. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_extracting.py +9 -2
  37. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_inline_snapshot.py +16 -0
  38. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_list.py +57 -7
  39. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_matchers.py +16 -0
  40. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_property_based.py +33 -5
  41. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_pytest_plugin.py +164 -4
  42. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_satisfy.py +22 -0
  43. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_snapshots.py +90 -0
  44. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_soft.py +39 -0
  45. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_structural.py +41 -0
  46. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_vacuity_contract.py +77 -1
  47. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_warn.py +24 -1
  48. {assertpy2-2.17.0 → assertpy2-2.18.0}/uv.lock +23 -23
  49. {assertpy2-2.17.0 → assertpy2-2.18.0}/.codecov.yml +0 -0
  50. {assertpy2-2.17.0 → assertpy2-2.18.0}/.gitattributes +0 -0
  51. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/dependabot.yml +0 -0
  52. {assertpy2-2.17.0 → assertpy2-2.18.0}/.github/workflows/zizmor.yml +0 -0
  53. {assertpy2-2.17.0 → assertpy2-2.18.0}/.gitignore +0 -0
  54. {assertpy2-2.17.0 → assertpy2-2.18.0}/CONTRIBUTING.md +0 -0
  55. {assertpy2-2.17.0 → assertpy2-2.18.0}/LICENSE +0 -0
  56. {assertpy2-2.17.0 → assertpy2-2.18.0}/SECURITY.md +0 -0
  57. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_engine/__init__.py +0 -0
  58. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_engine/_compare.py +0 -0
  59. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_engine/_compat.py +0 -0
  60. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_engine/_diff.py +0 -0
  61. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_engine/_introspection.py +0 -0
  62. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/_inline.py +0 -0
  63. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/base.py +0 -0
  64. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/behave_matchers.py +0 -0
  65. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/bytes_mixin.py +0 -0
  66. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/dataframe.py +0 -0
  67. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/dict.py +0 -0
  68. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/dynamic.py +0 -0
  69. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/exception.py +0 -0
  70. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/helpers.py +0 -0
  71. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/json_mixin.py +0 -0
  72. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/matchers.py +0 -0
  73. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/numeric.py +0 -0
  74. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/py.typed +0 -0
  75. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/string.py +0 -0
  76. {assertpy2-2.17.0 → assertpy2-2.18.0}/assertpy2/warning.py +0 -0
  77. {assertpy2-2.17.0 → assertpy2-2.18.0}/benchmarks/test_perf.py +0 -0
  78. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/assets/diff-equal.png +0 -0
  79. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/assets/diff-equal.svg +0 -0
  80. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/assets/diff-gallery.png +0 -0
  81. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/assets/diff-match.svg +0 -0
  82. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/assets/diff-sequence.svg +0 -0
  83. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/assets/diff-set.svg +0 -0
  84. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/assets/diff-string.svg +0 -0
  85. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/concepts/type-safety.md +0 -0
  86. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/extending/custom-assertions.md +0 -0
  87. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/extending/integrations.md +0 -0
  88. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/getting-started/quickstart.md +0 -0
  89. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/guides/data.md +0 -0
  90. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/guides/fluent.md +0 -0
  91. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/guides/matchers.md +0 -0
  92. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/index.md +0 -0
  93. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/logo-dark.svg +0 -0
  94. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/logo.svg +0 -0
  95. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/recipes.md +0 -0
  96. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/async.md +0 -0
  97. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/bytes.md +0 -0
  98. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/collections.md +0 -0
  99. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/containment.md +0 -0
  100. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/core.md +0 -0
  101. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/dataframes.md +0 -0
  102. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/dates.md +0 -0
  103. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/dicts.md +0 -0
  104. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/dynamic.md +0 -0
  105. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/entry-points.md +0 -0
  106. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/errors.md +0 -0
  107. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/exceptions.md +0 -0
  108. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/extracting.md +0 -0
  109. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/files.md +0 -0
  110. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/json.md +0 -0
  111. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/matchers.md +0 -0
  112. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/numbers.md +0 -0
  113. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/overview.md +0 -0
  114. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/snapshots.md +0 -0
  115. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/strings.md +0 -0
  116. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/reference/warnings.md +0 -0
  117. {assertpy2-2.17.0 → assertpy2-2.18.0}/docs/stylesheets/extra.css +0 -0
  118. {assertpy2-2.17.0 → assertpy2-2.18.0}/mkdocs.yml +0 -0
  119. {assertpy2-2.17.0 → assertpy2-2.18.0}/mkdocs_hooks.py +0 -0
  120. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/conftest.py +0 -0
  121. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_attrs_support.py +0 -0
  122. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_behave_matchers.py +0 -0
  123. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_bool.py +0 -0
  124. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_boundary_cases.py +0 -0
  125. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_bytes.py +0 -0
  126. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_callable.py +0 -0
  127. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_chaining.py +0 -0
  128. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_class.py +0 -0
  129. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_core.py +0 -0
  130. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_custom_dict.py +0 -0
  131. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_custom_list.py +0 -0
  132. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_dataframe.py +0 -0
  133. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_description.py +0 -0
  134. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_dict.py +0 -0
  135. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_dict_compare.py +0 -0
  136. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_docs_examples.py +0 -0
  137. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_dynamic.py +0 -0
  138. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_equals.py +0 -0
  139. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_errors.py +0 -0
  140. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_expected_exception.py +0 -0
  141. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_expected_warning.py +0 -0
  142. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_extensions.py +0 -0
  143. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_fail.py +0 -0
  144. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_file.py +0 -0
  145. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_grouped_soft.py +0 -0
  146. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_in.py +0 -0
  147. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_inline_record.py +0 -0
  148. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_iterable_cluster.py +0 -0
  149. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_json.py +0 -0
  150. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_matcher_registry.py +0 -0
  151. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_namedtuple.py +0 -0
  152. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_none.py +0 -0
  153. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_not.py +0 -0
  154. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_numbers.py +0 -0
  155. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_openapi_contract.py +0 -0
  156. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_overloads.py +0 -0
  157. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_pipeline.py +0 -0
  158. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_property_attrs.py +0 -0
  159. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_protocol_parity.py +0 -0
  160. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_readme.py +0 -0
  161. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_recursive_assertion.py +0 -0
  162. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_recursive_compare.py +0 -0
  163. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_recursive_compare_attrs.py +0 -0
  164. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_recursive_compare_config.py +0 -0
  165. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_recursive_compare_pydantic.py +0 -0
  166. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_regex_groups.py +0 -0
  167. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_rich_diff.py +0 -0
  168. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_same_as.py +0 -0
  169. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_soft_fail.py +0 -0
  170. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_string.py +0 -0
  171. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_sync_eventually.py +0 -0
  172. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_traceback.py +0 -0
  173. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_type.py +0 -0
  174. {assertpy2-2.17.0 → assertpy2-2.18.0}/tests/test_typing.py +0 -0
@@ -36,7 +36,7 @@ jobs:
36
36
  persist-credentials: false
37
37
 
38
38
  - name: Install uv
39
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
39
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
40
40
 
41
41
  - name: Set up Python ${{ matrix.python-version }}
42
42
  uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -85,7 +85,7 @@ jobs:
85
85
  persist-credentials: false
86
86
 
87
87
  - name: Install uv
88
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
88
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
89
89
 
90
90
  - name: Set up Python
91
91
  uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -20,7 +20,7 @@ jobs:
20
20
  - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21
21
  with:
22
22
  persist-credentials: false
23
- - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
23
+ - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
24
24
  with:
25
25
  languages: python
26
- - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
26
+ - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
@@ -31,7 +31,7 @@ jobs:
31
31
  persist-credentials: false
32
32
 
33
33
  - name: Install uv
34
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
34
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
35
35
 
36
36
  - name: Run benchmarks under CodSpeed
37
37
  uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
@@ -22,7 +22,7 @@ jobs:
22
22
  persist-credentials: false
23
23
 
24
24
  - name: Install uv
25
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
25
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
26
26
 
27
27
  - name: Set up Python
28
28
  uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -26,7 +26,7 @@ jobs:
26
26
  persist-credentials: false
27
27
 
28
28
  - name: Install uv
29
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
29
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
30
30
 
31
31
  - name: Set up Python
32
32
  uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -21,7 +21,7 @@ jobs:
21
21
  persist-credentials: false
22
22
 
23
23
  - name: Install uv
24
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
24
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
25
25
  with:
26
26
  enable-cache: false
27
27
 
@@ -29,6 +29,6 @@ jobs:
29
29
  name: SARIF file
30
30
  path: results.sarif
31
31
  retention-days: 5
32
- - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
32
+ - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
33
33
  with:
34
34
  sarif_file: results.sarif
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.17.0
3
+ Version: 2.18.0
4
4
  Summary: Fluent assertion library for Python with composable matchers, structural matching, and full type safety
5
5
  Project-URL: Homepage, https://github.com/Solganis/assertpy2
6
6
  Project-URL: Repository, https://github.com/Solganis/assertpy2
@@ -175,6 +175,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
175
175
  - [**Composable matchers**](https://solganis.github.io/assertpy2/guides/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`, usable in plain `assert ==`.
176
176
  - [**Structural matching**](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching): `matches_structure()` for declarative dict/API-response validation.
177
177
  - [**Recursive field assertions**](https://solganis.github.io/assertpy2/guides/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph.
178
+ - [**Vacuous-assertion guard**](https://solganis.github.io/assertpy2/guides/assertions/#assertions-that-checked-nothing): `--assertpy2-vacuous` warns when a universal assertion passes over an empty collection, having checked nothing.
178
179
  - [**Universal negation**](https://solganis.github.io/assertpy2/guides/fluent/#universal-negation): `.not_` inverts any assertion, no dedicated `is_not_*` methods.
179
180
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/guides/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
180
181
  - [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/guides/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`, plus `*_in_any_order` variants.
@@ -123,6 +123,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
123
123
  - [**Composable matchers**](https://solganis.github.io/assertpy2/guides/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`, usable in plain `assert ==`.
124
124
  - [**Structural matching**](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching): `matches_structure()` for declarative dict/API-response validation.
125
125
  - [**Recursive field assertions**](https://solganis.github.io/assertpy2/guides/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph.
126
+ - [**Vacuous-assertion guard**](https://solganis.github.io/assertpy2/guides/assertions/#assertions-that-checked-nothing): `--assertpy2-vacuous` warns when a universal assertion passes over an empty collection, having checked nothing.
126
127
  - [**Universal negation**](https://solganis.github.io/assertpy2/guides/fluent/#universal-negation): `.not_` inverts any assertion, no dedicated `is_not_*` methods.
127
128
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/guides/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
128
129
  - [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/guides/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`, plus `*_in_any_order` variants.
@@ -14,7 +14,7 @@ from .assertpy import (
14
14
  soft_fail,
15
15
  )
16
16
  from .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
17
- from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace
17
+ from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace, VacuousAssertionWarning
18
18
  from .file import contents_of
19
19
  from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
20
20
  from .snapshot import SnapshotCreatedWarning, SnapshotUpdatedWarning, register_snapshot_serializer
@@ -32,6 +32,7 @@ __all__ = [
32
32
  "SnapshotUpdatedWarning",
33
33
  "SoftAssertionCollector",
34
34
  "SyncAssertionBuilder",
35
+ "VacuousAssertionWarning",
35
36
  "WarningLoggingAdapter",
36
37
  "__version__",
37
38
  "add_extension",
@@ -86,7 +86,7 @@ def contract_drift(payload: object, model: Any, path: str = "") -> list[str]:
86
86
  return drift
87
87
 
88
88
 
89
- def shape(value: object) -> object:
89
+ def shape(value: object, _seen: frozenset[int] = frozenset()) -> object:
90
90
  """The structural shape of a value: paths and type *categories*, never values.
91
91
 
92
92
  Numbers collapse to one category (so ``5`` and ``5.0`` do not read as drift) and ``None`` becomes
@@ -101,10 +101,15 @@ def shape(value: object) -> object:
101
101
  return "number"
102
102
  if isinstance(value, str):
103
103
  return "str"
104
- if isinstance(value, dict):
105
- return {key: shape(item) for key, item in value.items()}
106
- if isinstance(value, (list, tuple)):
107
- element_shapes = [shape(item) for item in value]
104
+ if isinstance(value, (dict, list, tuple)):
105
+ if id(value) in _seen:
106
+ # a self-referential graph (an ORM backref, a tree with parent pointers) would otherwise
107
+ # recurse until the interpreter gives up; every sibling walker marks the cycle instead
108
+ return "<circular ref>"
109
+ nested = _seen | {id(value)}
110
+ if isinstance(value, dict):
111
+ return {key: shape(item, nested) for key, item in value.items()}
112
+ element_shapes = [shape(item, nested) for item in value]
108
113
  return [reduce(_merge, element_shapes)] if element_shapes else []
109
114
  return type(value).__name__
110
115
 
@@ -38,6 +38,7 @@ class _MixinBase:
38
38
  kind: str | None = ...,
39
39
  expected: type[BaseException] | None = ...,
40
40
  logger: logging.LoggerAdapter | None = ...,
41
+ origin: str | None = ...,
41
42
  ) -> Self: ...
42
43
 
43
44
  # WarningMixin (terminals dispatched from ExceptionMixin.when_called_with)
@@ -54,8 +54,8 @@ if TYPE_CHECKING:
54
54
  def is_callable(self) -> Self: ...
55
55
  def is_not_callable(self) -> Self: ...
56
56
  def satisfies(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
57
- def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
58
- def has_no_none_fields(self) -> Self: ...
57
+ def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
58
+ def has_no_none_fields(self, *, allow_empty: bool = ...) -> Self: ...
59
59
  # ContainsMixin - universal
60
60
  def is_in(self, *items: object) -> Self: ...
61
61
  def is_not_in(self, *items: object) -> Self: ...
@@ -135,7 +135,7 @@ if TYPE_CHECKING:
135
135
  def has_size_between(self, low: int, high: int) -> Self: ...
136
136
  def is_empty(self) -> Self: ...
137
137
  def is_not_empty(self) -> Self: ...
138
- def is_subset_of(self, *supersets: object) -> Self: ...
138
+ def is_subset_of(self, *supersets: object, allow_empty: bool = ...) -> Self: ...
139
139
  # StringMixin - regex
140
140
  def extracting_group(self, pattern: str, group: int | str = ...) -> Self: ...
141
141
  def matches_with_groups(self, pattern: str) -> Self: ...
@@ -203,18 +203,22 @@ if TYPE_CHECKING:
203
203
  # CollectionMixin
204
204
  def is_iterable(self) -> Self: ...
205
205
  def is_not_iterable(self) -> Self: ...
206
- def is_subset_of(self, *supersets: object) -> Self: ...
207
- def is_sorted(self, key: Callable[..., object] = ..., reverse: bool = ...) -> Self: ...
206
+ def is_subset_of(self, *supersets: object, allow_empty: bool = ...) -> Self: ...
207
+ def is_sorted(
208
+ self, key: Callable[..., object] = ..., reverse: bool = ..., *, allow_empty: bool = ...
209
+ ) -> Self: ...
208
210
  # ExtractingMixin - extracted values have a dynamic type, so the element type is dropped
209
211
  def extracting(self, *names: object, **kwargs: object) -> _IterableAssertion[Any]: ...
210
212
  # BaseMixin
211
- def each(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
213
+ def each(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
212
214
  def any_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
213
- def all_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
215
+ def all_satisfy(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
214
216
  def none_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
215
217
  def satisfies_exactly(self, *matchers: Matcher | Callable[..., bool]) -> Self: ...
216
218
  def satisfies_exactly_in_any_order(self, *matchers: Matcher | Callable[..., bool]) -> Self: ...
217
- def zip_satisfies(self, other: Iterable[object], predicate: Callable[..., bool]) -> Self: ...
219
+ def zip_satisfies(
220
+ self, other: Iterable[object], predicate: Callable[..., bool], *, allow_empty: bool = ...
221
+ ) -> Self: ...
218
222
  def contains_only_once(self, *items: object) -> Self: ...
219
223
  def has_same_size_as(self, other: object) -> Self: ...
220
224
  def has_size_greater_than(self, size: int) -> Self: ...
@@ -267,7 +271,7 @@ if TYPE_CHECKING:
267
271
  def is_not_empty(self) -> Self: ...
268
272
  # BaseMixin
269
273
  def matches_structure(self, spec: dict) -> Self: ...
270
- def each(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
274
+ def each(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
271
275
  # ExtractingMixin - extracted values have a dynamic type, so the key/value types are dropped
272
276
  def extracting(self, *names: object, **kwargs: object) -> _DictAssertion[Any, Any]: ...
273
277
  # JsonMixin - navigation yields a dynamically-typed value, so the key/value types are dropped
@@ -48,6 +48,16 @@ def _is_matcher(obj: object) -> TypeIs[Matcher]:
48
48
  return isinstance(obj, Matcher)
49
49
 
50
50
 
51
+ def _require_matcher(operand: object, operator: str) -> None:
52
+ """Reject a non-matcher operand where the combinator is built, not where it is later applied.
53
+
54
+ Combining silently would hand back a matcher that raises ``AttributeError: 'int' object has no
55
+ attribute 'matches'`` at assertion time, far from the line that built it.
56
+ """
57
+ if not _is_matcher(operand):
58
+ raise TypeError(f"cannot combine a Matcher with <{type(operand).__name__}> using '{operator}'")
59
+
60
+
51
61
  class BaseMatcher:
52
62
  """Abstract base for all matchers with operator support."""
53
63
 
@@ -61,11 +71,13 @@ class BaseMatcher:
61
71
  return f"was <{value}>"
62
72
 
63
73
  def __and__(self, other: Matcher) -> AllOfMatcher:
74
+ _require_matcher(other, "&")
64
75
  left = list(self.matchers) if isinstance(self, AllOfMatcher) else [self]
65
76
  right = list(other.matchers) if isinstance(other, AllOfMatcher) else [other]
66
77
  return AllOfMatcher(*left, *right)
67
78
 
68
79
  def __or__(self, other: Matcher) -> AnyOfMatcher:
80
+ _require_matcher(other, "|")
69
81
  left = list(self.matchers) if isinstance(self, AnyOfMatcher) else [self]
70
82
  right = list(other.matchers) if isinstance(other, AnyOfMatcher) else [other]
71
83
  return AnyOfMatcher(*left, *right)
@@ -1,12 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import collections.abc
4
+ import os
5
+ import warnings
4
6
  from typing import TYPE_CHECKING, Any
5
7
 
6
8
  from ._engine._diff import _walk_leaves
7
9
  from ._engine._introspection import is_attrs_instance, is_model_dump_object
8
10
  from ._engine._mixin_base import _MixinBase
9
- from .errors import DiffEntry, DiffResult
11
+ from .errors import DiffEntry, DiffResult, VacuousAssertionWarning
10
12
  from .matchers import IsNotNoneMatcher, Matcher, StructureMatcher, _apply_matcher, _describe_matcher, _is_matcher
11
13
 
12
14
  if TYPE_CHECKING:
@@ -47,10 +49,53 @@ def _max_bipartite_assignment(satisfied: list[list[bool]]) -> list[int | None]:
47
49
  return assignment
48
50
 
49
51
 
52
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
53
+
54
+
55
+ def _env_enabled() -> bool:
56
+ """Read the environment switch, at import and from the pytest plugin rather than per assertion.
57
+
58
+ The switch cannot change within a process, and the lookup with its two string operations costs
59
+ around 300ns: more than the emptiness check it decides to skip, on every quantifier call.
60
+ """
61
+ return os.environ.get("ASSERTPY2_VACUOUS", "").strip().lower() in _TRUTHY
62
+
63
+
64
+ _VACUOUS_GUARD: bool = _env_enabled()
65
+ """Whether a universal assertion over an empty value warns.
66
+
67
+ Off by default: a suite running ``filterwarnings = ["error"]`` would otherwise fail on upgrade, and a
68
+ property-based test generates empty collections as a matter of course. Turn it on with the pytest
69
+ ``--assertpy2-vacuous`` flag or the ``ASSERTPY2_VACUOUS`` environment variable for other runners.
70
+ """
71
+
72
+
73
+ def _warn_if_vacuous(name: str, value: object, allow_empty: bool) -> None:
74
+ """Warn when a universal assertion is about to pass because there is nothing to quantify over.
75
+
76
+ Called from each public entry point rather than from the shared implementation, so the name in the
77
+ message is the one the caller used and ``stacklevel`` lands on their line rather than ours.
78
+ """
79
+ if allow_empty or not _VACUOUS_GUARD:
80
+ return
81
+ try:
82
+ empty = len(value) == 0 # ty: ignore[invalid-argument-type] # guarded by the except below
83
+ except Exception: # a diagnostic must never crash the assertion it is watching
84
+ # no usable len(): an unsized value (a one-shot iterable) or a broken __len__. Either way,
85
+ # stay out of the way rather than draining the value or surfacing its error as our own.
86
+ return
87
+ if empty:
88
+ warnings.warn(
89
+ f"{name}() passed over an empty value, so nothing was checked. Pass allow_empty=True if that is intended.",
90
+ VacuousAssertionWarning,
91
+ stacklevel=3,
92
+ )
93
+
94
+
50
95
  class SatisfiesMixin(_MixinBase):
51
96
  """Predicate and matcher-application assertions: satisfies / each / *_satisfy / matches_structure."""
52
97
 
53
- def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self:
98
+ def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = False) -> Self:
54
99
  """Asserts that every scalar leaf in val's object graph satisfies the given matcher.
55
100
 
56
101
  Walks val recursively (mappings, dataclasses, namedtuples, Pydantic models, lists, tuples) and
@@ -75,6 +120,7 @@ class SatisfiesMixin(_MixinBase):
75
120
  AssertionError: if any leaf does **not** satisfy the matcher
76
121
  TypeError: if matcher is neither a Matcher nor callable
77
122
  """
123
+ _warn_if_vacuous("all_fields_satisfy", self.val, allow_empty)
78
124
  if not _is_matcher(matcher) and not callable(matcher):
79
125
  raise TypeError("given arg must be a Matcher or callable")
80
126
  description = _describe_matcher(matcher)
@@ -94,7 +140,7 @@ class SatisfiesMixin(_MixinBase):
94
140
  )
95
141
  return self
96
142
 
97
- def has_no_none_fields(self) -> Self:
143
+ def has_no_none_fields(self, *, allow_empty: bool = False) -> Self:
98
144
  """Asserts that no scalar leaf in val's object graph is ``None``.
99
145
 
100
146
  Convenience wrapper over `all_fields_satisfy()` with a not-``None`` matcher; reports the path
@@ -111,7 +157,8 @@ class SatisfiesMixin(_MixinBase):
111
157
  Raises:
112
158
  AssertionError: if any leaf **is** ``None``
113
159
  """
114
- return self.all_fields_satisfy(IsNotNoneMatcher())
160
+ _warn_if_vacuous("has_no_none_fields", self.val, allow_empty)
161
+ return self.all_fields_satisfy(IsNotNoneMatcher(), allow_empty=True)
115
162
 
116
163
  def satisfies(self, matcher: Matcher | Callable[..., bool]) -> Self:
117
164
  """Asserts that val satisfies the given matcher.
@@ -159,7 +206,7 @@ class SatisfiesMixin(_MixinBase):
159
206
  raise TypeError("given arg must be a Matcher or callable")
160
207
  return self
161
208
 
162
- def each(self, matcher: Matcher | Callable[..., bool]) -> Self:
209
+ def each(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = False) -> Self:
163
210
  """Asserts that every item in val satisfies the given matcher.
164
211
 
165
212
  Args:
@@ -184,6 +231,7 @@ class SatisfiesMixin(_MixinBase):
184
231
  Raises:
185
232
  AssertionError: if any item does **not** satisfy the matcher
186
233
  """
234
+ _warn_if_vacuous("each", self.val, allow_empty)
187
235
  if not isinstance(self.val, collections.abc.Iterable):
188
236
  raise TypeError("val is not iterable")
189
237
  if _is_matcher(matcher):
@@ -322,17 +370,29 @@ class SatisfiesMixin(_MixinBase):
322
370
  """
323
371
  if not isinstance(self.val, collections.abc.Iterable):
324
372
  raise TypeError("val is not iterable")
325
- if _is_matcher(matcher):
326
- if not any(matcher.matches(item) for item in self.val):
327
- return self.error(f"Expected any item to satisfy {matcher.describe()}, but none did.")
328
- elif callable(matcher):
329
- if not any(matcher(item) for item in self.val):
330
- return self.error(f"Expected any item to satisfy {_describe_matcher(matcher)}, but none did.")
373
+ description = _describe_matcher(matcher)
374
+ if _is_matcher(matcher) or callable(matcher):
375
+ if not any(_apply_matcher(matcher, item) for item in self.val):
376
+ # "none did" alone leaves the reader to fetch the items themselves; the universal
377
+ # sibling already lists every one that failed
378
+ items = list(self.val)
379
+ return self.error(
380
+ f"Expected any item to satisfy {description}, but none of the {len(items)} did.",
381
+ actual=self.val,
382
+ expected=description,
383
+ diff=DiffResult(
384
+ kind="match",
385
+ entries=[
386
+ DiffEntry(path=f"[{index}]", actual=item, expected=description)
387
+ for index, item in enumerate(items[:5])
388
+ ],
389
+ ),
390
+ )
331
391
  else:
332
392
  raise TypeError("given arg must be a Matcher or callable")
333
393
  return self
334
394
 
335
- def all_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self:
395
+ def all_satisfy(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = False) -> Self:
336
396
  """Asserts that all items in val satisfy the given matcher.
337
397
 
338
398
  Semantic alias for [`each()`][assertpy2.base.BaseMixin.each].
@@ -354,7 +414,8 @@ class SatisfiesMixin(_MixinBase):
354
414
  Raises:
355
415
  AssertionError: if any item does **not** satisfy the matcher
356
416
  """
357
- return self.each(matcher)
417
+ _warn_if_vacuous("all_satisfy", self.val, allow_empty)
418
+ return self.each(matcher, allow_empty=True)
358
419
 
359
420
  def none_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self:
360
421
  """Asserts that no item in val satisfies the given matcher.
@@ -533,7 +594,9 @@ class SatisfiesMixin(_MixinBase):
533
594
  )
534
595
  return self
535
596
 
536
- def zip_satisfies(self, other: Iterable[object], predicate: Callable[..., bool]) -> Self:
597
+ def zip_satisfies(
598
+ self, other: Iterable[object], predicate: Callable[..., bool], *, allow_empty: bool = False
599
+ ) -> Self:
537
600
  """Asserts that each pair from zipping val with other satisfies the two-arg predicate.
538
601
 
539
602
  Pairs the i-th item of val with the i-th item of ``other`` and checks ``predicate(a, b)``.
@@ -556,6 +619,7 @@ class SatisfiesMixin(_MixinBase):
556
619
  AssertionError: if the lengths differ, or any pair does **not** satisfy the predicate
557
620
  TypeError: if val or other is not iterable, or predicate is not callable
558
621
  """
622
+ _warn_if_vacuous("zip_satisfies", self.val, allow_empty)
559
623
  if not callable(predicate):
560
624
  raise TypeError("given predicate must be callable")
561
625
  if not isinstance(self.val, collections.abc.Iterable):
@@ -31,19 +31,27 @@ _SERIALIZERS: list[_Serializer] = []
31
31
  _RESERVED_MARKERS = frozenset({"__type__", "__data__", "__tag__", "__class__", "__module__"})
32
32
 
33
33
 
34
- def _prepare(value):
34
+ def _prepare(value, _seen: frozenset[int] = frozenset()):
35
35
  """Pre-process a value so ``json`` round-trips dicts that it otherwise mangles: dicts with a
36
36
  non-string key (which ``json`` coerces to a string) and dicts that collide with the codec's markers
37
37
  (which the decoder would misread). Both are wrapped in a ``{"__type__": "dict", "__data__": [[k,
38
38
  v], ...]}`` envelope; a plain string-keyed dict without markers is left as an ordinary object so
39
39
  existing snapshots stay byte-identical."""
40
- if isinstance(value, dict):
41
- if all(isinstance(key, str) for key in value) and not _RESERVED_MARKERS.intersection(value):
42
- return {key: _prepare(item) for key, item in value.items()}
43
- items = sorted(value.items(), key=lambda pair: repr(pair[0])) # deterministic order for mixed keys
44
- return {"__type__": "dict", "__data__": [[_prepare(key), _prepare(item)] for key, item in items]}
45
- if isinstance(value, (list, tuple)):
46
- return [_prepare(item) for item in value]
40
+ if isinstance(value, (dict, list, tuple)):
41
+ if id(value) in _seen:
42
+ # json cannot represent a cycle at all, so the only question is how this fails: naming the
43
+ # cycle beats a thousand frames of RecursionError from an unguarded walk
44
+ raise ValueError("cannot snapshot a value that contains a circular reference")
45
+ nested = _seen | {id(value)}
46
+ if isinstance(value, dict):
47
+ if all(isinstance(key, str) for key in value) and not _RESERVED_MARKERS.intersection(value):
48
+ return {key: _prepare(item, nested) for key, item in value.items()}
49
+ items = sorted(value.items(), key=lambda pair: repr(pair[0])) # deterministic for mixed keys
50
+ return {
51
+ "__type__": "dict",
52
+ "__data__": [[_prepare(key, nested), _prepare(item, nested)] for key, item in items],
53
+ }
54
+ return [_prepare(item, nested) for item in value]
47
55
  return value
48
56
 
49
57