assertpy2 2.18.0__tar.gz → 2.19.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 (178) hide show
  1. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/workflows/ci.yml +9 -6
  2. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/workflows/codeql.yml +4 -3
  3. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/workflows/codspeed.yml +4 -3
  4. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/workflows/docs.yml +5 -3
  5. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/workflows/mutation.yml +3 -3
  6. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/workflows/publish.yml +5 -4
  7. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/workflows/scorecard.yml +4 -3
  8. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/workflows/zizmor.yml +3 -2
  9. {assertpy2-2.18.0 → assertpy2-2.19.0}/PKG-INFO +23 -2
  10. {assertpy2-2.18.0 → assertpy2-2.19.0}/README.md +22 -1
  11. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/__init__.py +7 -1
  12. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_engine/_compare.py +94 -7
  13. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_engine/_diff.py +250 -49
  14. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_engine/_mixin_base.py +1 -0
  15. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_engine/_typing.py +11 -2
  16. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_matcher_impls.py +50 -3
  17. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/assertpy.py +40 -10
  18. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/async_assertions.py +5 -1
  19. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/base.py +49 -13
  20. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/dataframe.py +5 -3
  21. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/errors.py +68 -24
  22. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/file.py +1 -3
  23. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/helpers.py +14 -2
  24. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/json_mixin.py +26 -7
  25. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/matchers.py +15 -3
  26. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/pytest_plugin.py +56 -3
  27. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/snapshot.py +121 -4
  28. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/string.py +4 -2
  29. {assertpy2-2.18.0 → assertpy2-2.19.0}/benchmarks/test_perf.py +65 -0
  30. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/concepts/type-safety.md +26 -0
  31. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/extending/custom-assertions.md +23 -0
  32. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/guides/assertions.md +83 -0
  33. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/guides/errors.md +22 -0
  34. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/guides/fluent.md +11 -0
  35. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/guides/testing.md +35 -0
  36. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/snapshots.md +6 -0
  37. {assertpy2-2.18.0 → assertpy2-2.19.0}/pyproject.toml +18 -5
  38. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_async.py +45 -0
  39. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_dataframe.py +21 -0
  40. assertpy2-2.19.0/tests/test_docs_typing.py +77 -0
  41. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_equals.py +26 -0
  42. assertpy2-2.19.0/tests/test_exception_context.py +73 -0
  43. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_json.py +27 -1
  44. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_matchers.py +76 -0
  45. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_property_based.py +133 -1
  46. assertpy2-2.19.0/tests/test_protocol_parity.py +219 -0
  47. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_pytest_plugin.py +172 -0
  48. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_recursive_compare_config.py +286 -2
  49. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_rich_diff.py +252 -4
  50. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_snapshots.py +39 -1
  51. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_structural.py +16 -0
  52. assertpy2-2.19.0/tests/test_traceback.py +86 -0
  53. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_typing.py +20 -0
  54. {assertpy2-2.18.0 → assertpy2-2.19.0}/uv.lock +585 -587
  55. assertpy2-2.18.0/tests/test_protocol_parity.py +0 -86
  56. assertpy2-2.18.0/tests/test_traceback.py +0 -38
  57. {assertpy2-2.18.0 → assertpy2-2.19.0}/.codecov.yml +0 -0
  58. {assertpy2-2.18.0 → assertpy2-2.19.0}/.gitattributes +0 -0
  59. {assertpy2-2.18.0 → assertpy2-2.19.0}/.github/dependabot.yml +0 -0
  60. {assertpy2-2.18.0 → assertpy2-2.19.0}/.gitignore +0 -0
  61. {assertpy2-2.18.0 → assertpy2-2.19.0}/CONTRIBUTING.md +0 -0
  62. {assertpy2-2.18.0 → assertpy2-2.19.0}/LICENSE +0 -0
  63. {assertpy2-2.18.0 → assertpy2-2.19.0}/SECURITY.md +0 -0
  64. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_engine/__init__.py +0 -0
  65. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_engine/_compat.py +0 -0
  66. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_engine/_contract.py +0 -0
  67. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_engine/_introspection.py +0 -0
  68. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_inline.py +0 -0
  69. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_satisfies.py +0 -0
  70. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/_snapshot_codec.py +0 -0
  71. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/behave_matchers.py +0 -0
  72. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/bytes_mixin.py +0 -0
  73. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/collection.py +0 -0
  74. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/contains.py +0 -0
  75. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/date.py +0 -0
  76. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/dict.py +0 -0
  77. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/dynamic.py +0 -0
  78. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/exception.py +0 -0
  79. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/extracting.py +0 -0
  80. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/numeric.py +0 -0
  81. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/py.typed +0 -0
  82. {assertpy2-2.18.0 → assertpy2-2.19.0}/assertpy2/warning.py +0 -0
  83. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/assets/diff-equal.png +0 -0
  84. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/assets/diff-equal.svg +0 -0
  85. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/assets/diff-gallery.png +0 -0
  86. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/assets/diff-match.svg +0 -0
  87. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/assets/diff-sequence.svg +0 -0
  88. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/assets/diff-set.svg +0 -0
  89. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/assets/diff-string.svg +0 -0
  90. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/extending/integrations.md +0 -0
  91. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/getting-started/comparison.md +0 -0
  92. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/getting-started/migration.md +0 -0
  93. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/getting-started/quickstart.md +0 -0
  94. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/guides/data.md +0 -0
  95. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/guides/matchers.md +0 -0
  96. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/index.md +0 -0
  97. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/logo-dark.svg +0 -0
  98. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/logo.svg +0 -0
  99. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/recipes.md +0 -0
  100. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/async.md +0 -0
  101. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/bytes.md +0 -0
  102. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/collections.md +0 -0
  103. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/containment.md +0 -0
  104. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/core.md +0 -0
  105. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/dataframes.md +0 -0
  106. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/dates.md +0 -0
  107. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/dicts.md +0 -0
  108. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/dynamic.md +0 -0
  109. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/entry-points.md +0 -0
  110. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/errors.md +0 -0
  111. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/exceptions.md +0 -0
  112. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/extracting.md +0 -0
  113. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/files.md +0 -0
  114. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/json.md +0 -0
  115. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/matchers.md +0 -0
  116. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/numbers.md +0 -0
  117. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/overview.md +0 -0
  118. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/strings.md +0 -0
  119. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/reference/warnings.md +0 -0
  120. {assertpy2-2.18.0 → assertpy2-2.19.0}/docs/stylesheets/extra.css +0 -0
  121. {assertpy2-2.18.0 → assertpy2-2.19.0}/mkdocs.yml +0 -0
  122. {assertpy2-2.18.0 → assertpy2-2.19.0}/mkdocs_hooks.py +0 -0
  123. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/conftest.py +0 -0
  124. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_attrs_support.py +0 -0
  125. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_behave_matchers.py +0 -0
  126. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_bool.py +0 -0
  127. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_boundary_cases.py +0 -0
  128. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_bytes.py +0 -0
  129. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_callable.py +0 -0
  130. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_chaining.py +0 -0
  131. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_class.py +0 -0
  132. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_collection.py +0 -0
  133. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_core.py +0 -0
  134. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_custom_dict.py +0 -0
  135. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_custom_list.py +0 -0
  136. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_datetime.py +0 -0
  137. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_description.py +0 -0
  138. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_dict.py +0 -0
  139. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_dict_compare.py +0 -0
  140. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_docs_examples.py +0 -0
  141. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_dynamic.py +0 -0
  142. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_errors.py +0 -0
  143. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_expected_exception.py +0 -0
  144. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_expected_warning.py +0 -0
  145. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_extensions.py +0 -0
  146. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_extracting.py +0 -0
  147. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_fail.py +0 -0
  148. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_file.py +0 -0
  149. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_grouped_soft.py +0 -0
  150. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_in.py +0 -0
  151. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_inline_record.py +0 -0
  152. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_inline_snapshot.py +0 -0
  153. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_iterable_cluster.py +0 -0
  154. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_list.py +0 -0
  155. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_matcher_registry.py +0 -0
  156. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_namedtuple.py +0 -0
  157. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_none.py +0 -0
  158. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_not.py +0 -0
  159. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_numbers.py +0 -0
  160. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_openapi_contract.py +0 -0
  161. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_overloads.py +0 -0
  162. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_pipeline.py +0 -0
  163. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_property_attrs.py +0 -0
  164. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_readme.py +0 -0
  165. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_recursive_assertion.py +0 -0
  166. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_recursive_compare.py +0 -0
  167. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_recursive_compare_attrs.py +0 -0
  168. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_recursive_compare_pydantic.py +0 -0
  169. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_regex_groups.py +0 -0
  170. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_same_as.py +0 -0
  171. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_satisfy.py +0 -0
  172. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_soft.py +0 -0
  173. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_soft_fail.py +0 -0
  174. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_string.py +0 -0
  175. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_sync_eventually.py +0 -0
  176. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_type.py +0 -0
  177. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_vacuity_contract.py +0 -0
  178. {assertpy2-2.18.0 → assertpy2-2.19.0}/tests/test_warn.py +0 -0
@@ -15,6 +15,7 @@ permissions:
15
15
 
16
16
  jobs:
17
17
  test:
18
+ timeout-minutes: 15
18
19
  runs-on: ${{ matrix.os }}
19
20
  strategy:
20
21
  matrix:
@@ -31,15 +32,15 @@ jobs:
31
32
  - os: macos-latest
32
33
  python-version: "3.14"
33
34
  steps:
34
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
35
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35
36
  with:
36
37
  persist-credentials: false
37
38
 
38
39
  - name: Install uv
39
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
40
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
40
41
 
41
42
  - name: Set up Python ${{ matrix.python-version }}
42
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
43
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
43
44
  with:
44
45
  python-version: ${{ matrix.python-version }}
45
46
  allow-prereleases: true
@@ -78,17 +79,18 @@ jobs:
78
79
  files: coverage.xml
79
80
 
80
81
  lint:
82
+ timeout-minutes: 10
81
83
  runs-on: ubuntu-latest
82
84
  steps:
83
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
85
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
84
86
  with:
85
87
  persist-credentials: false
86
88
 
87
89
  - name: Install uv
88
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
90
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
89
91
 
90
92
  - name: Set up Python
91
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
93
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
92
94
  with:
93
95
  python-version: "3.14"
94
96
 
@@ -111,6 +113,7 @@ jobs:
111
113
  run: uv run pyright tests/test_typing.py
112
114
 
113
115
  ci-ok:
116
+ timeout-minutes: 5
114
117
  needs: [test, lint]
115
118
  if: always()
116
119
  runs-on: ubuntu-latest
@@ -13,14 +13,15 @@ permissions:
13
13
 
14
14
  jobs:
15
15
  analyze:
16
+ timeout-minutes: 15
16
17
  runs-on: ubuntu-latest
17
18
  permissions:
18
19
  security-events: write
19
20
  steps:
20
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
22
  with:
22
23
  persist-credentials: false
23
- - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
24
+ - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
24
25
  with:
25
26
  languages: python
26
- - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
27
+ - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
@@ -21,20 +21,21 @@ concurrency:
21
21
 
22
22
  jobs:
23
23
  benchmarks:
24
+ timeout-minutes: 20
24
25
  runs-on: ubuntu-latest
25
26
  permissions:
26
27
  contents: read
27
28
  id-token: write
28
29
  steps:
29
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
30
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
30
31
  with:
31
32
  persist-credentials: false
32
33
 
33
34
  - name: Install uv
34
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
35
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
35
36
 
36
37
  - name: Run benchmarks under CodSpeed
37
- uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
38
+ uses: CodSpeedHQ/action@f22792bfac16f3e14eb9fbea76f4a48e9cc22b93 # v4.19.1
38
39
  with:
39
40
  mode: simulation
40
41
  run: uv run --locked --group benchmark pytest benchmarks/ --codspeed --no-cov
@@ -15,17 +15,18 @@ concurrency:
15
15
 
16
16
  jobs:
17
17
  build:
18
+ timeout-minutes: 10
18
19
  runs-on: ubuntu-latest
19
20
  steps:
20
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
22
  with:
22
23
  persist-credentials: false
23
24
 
24
25
  - name: Install uv
25
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
26
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
26
27
 
27
28
  - name: Set up Python
28
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
29
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
29
30
  with:
30
31
  python-version: "3.14"
31
32
 
@@ -42,6 +43,7 @@ jobs:
42
43
  path: site
43
44
 
44
45
  deploy:
46
+ timeout-minutes: 10
45
47
  if: github.ref == 'refs/heads/main'
46
48
  needs: build
47
49
  runs-on: ubuntu-latest
@@ -21,15 +21,15 @@ jobs:
21
21
  matrix:
22
22
  module: [base, numeric, string, contains, collection, dict, date, matchers, helpers, _compare, dataframe, json_mixin, snapshot, bytes_mixin, dynamic, extracting, warning, exception, async_assertions, file, behave_matchers]
23
23
  steps:
24
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
24
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
25
25
  with:
26
26
  persist-credentials: false
27
27
 
28
28
  - name: Install uv
29
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
29
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
30
30
 
31
31
  - name: Set up Python
32
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
32
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
33
33
  with:
34
34
  python-version: "3.14"
35
35
 
@@ -9,6 +9,7 @@ permissions:
9
9
 
10
10
  jobs:
11
11
  publish:
12
+ timeout-minutes: 20
12
13
  runs-on: ubuntu-latest
13
14
  environment: pypi
14
15
  permissions:
@@ -16,17 +17,17 @@ jobs:
16
17
  contents: write
17
18
  attestations: write
18
19
  steps:
19
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
21
  with:
21
22
  persist-credentials: false
22
23
 
23
24
  - name: Install uv
24
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
25
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
25
26
  with:
26
27
  enable-cache: false
27
28
 
28
29
  - name: Set up Python
29
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
30
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
30
31
  with:
31
32
  python-version: "3.15"
32
33
  allow-prereleases: true
@@ -65,7 +66,7 @@ jobs:
65
66
  run: syft scan dir:dist -o "cyclonedx-json=provenance/assertpy2.sbom.cdx.json"
66
67
 
67
68
  - name: Publish to PyPI
68
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
69
+ uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
69
70
 
70
71
  - name: Upload to GitHub Release
71
72
  run: gh release upload "$TAG_NAME" dist/* provenance/*
@@ -11,15 +11,16 @@ permissions: read-all
11
11
 
12
12
  jobs:
13
13
  analysis:
14
+ timeout-minutes: 10
14
15
  runs-on: ubuntu-latest
15
16
  permissions:
16
17
  security-events: write
17
18
  id-token: write
18
19
  steps:
19
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
21
  with:
21
22
  persist-credentials: false
22
- - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
23
+ - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
23
24
  with:
24
25
  results_file: results.sarif
25
26
  results_format: sarif
@@ -29,6 +30,6 @@ jobs:
29
30
  name: SARIF file
30
31
  path: results.sarif
31
32
  retention-days: 5
32
- - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
33
+ - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
33
34
  with:
34
35
  sarif_file: results.sarif
@@ -15,11 +15,12 @@ permissions:
15
15
 
16
16
  jobs:
17
17
  zizmor:
18
+ timeout-minutes: 10
18
19
  runs-on: ubuntu-latest
19
20
  permissions:
20
21
  security-events: write
21
22
  steps:
22
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
23
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
24
  with:
24
25
  persist-credentials: false
25
- - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
26
+ - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.18.0
3
+ Version: 2.19.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
@@ -88,9 +88,9 @@ def test_user():
88
88
  user = {"name": "Alice", "age": 30, "roles": ["viewer", "editor"]}
89
89
 
90
90
  assert_that(user).contains_key("name", "age")
91
+ assert_that(user).contains_entry({"name": "Alice"})
91
92
  assert_that(user["age"]).is_between(18, 120)
92
93
  assert_that(user["roles"]).contains("viewer").does_not_contain("admin")
93
- assert_that(user).has_name("Alice")
94
94
  ```
95
95
 
96
96
  The [full documentation](https://solganis.github.io/assertpy2/) covers every assertion, matcher, and integration.
@@ -100,6 +100,7 @@ The [full documentation](https://solganis.github.io/assertpy2/) covers every ass
100
100
  A fluent chain reads as one intent and replaces several bare asserts -<br>
101
101
  and your IDE offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/concepts/type-safety/):
102
102
 
103
+ <!-- docs-guard: skip -->
103
104
  ```python
104
105
  # bare - three statements, no autocomplete help
105
106
  assert isinstance(items, list)
@@ -123,6 +124,7 @@ E {'status': 'active'} != {'status': 'disabled'}
123
124
 
124
125
  assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs), in color:
125
126
 
127
+ <!-- docs-guard: skip -->
126
128
  ```python
127
129
  assert_that(response).is_equal_to(expected)
128
130
  ```
@@ -133,6 +135,23 @@ assert_that(response).is_equal_to(expected)
133
135
 
134
136
  The diff recurses through nested containers, and matcher predicates get the same path-level treatment. For dynamic fields like IDs or timestamps, assert a subset with [`matches_structure()`](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching).
135
137
 
138
+ Matchers are ordinary values, so they also compose inside the expected structure itself, at any depth,
139
+ with or without the fluent chain:
140
+
141
+ ```python
142
+ response = {"id": 7, "user": {"name": "Alice", "age": 30}, "tags": ["a", "b"]}
143
+
144
+ assert_that(response).is_equal_to(
145
+ {"id": match.greater_than(0), "user": {"name": "Alice", "age": match.between(18, 120)}, "tags": ["a", "b"]}
146
+ )
147
+
148
+ # or keep the bare `assert`, and pytest's own rewriting reports it
149
+ assert response == {"id": match.greater_than(0), "user": match.ignore(), "tags": ["a", "b"]}
150
+ ```
151
+
152
+ The fluent form keeps the path-level diff, the bare form keeps pytest's. There are
153
+ [39 matchers](https://solganis.github.io/assertpy2/guides/matchers/), and they combine with `&`, `|` and `~`.
154
+
136
155
  <p align="center">
137
156
  <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-gallery.png" width="640" alt="Structured diffs in the terminal: dict path, list element, set extra/missing, and structural-matcher predicate diffs, side by side">
138
157
  </p>
@@ -156,6 +175,7 @@ Works in PyCharm, VS Code, and any LSP-compatible editor.
156
175
  An assertion hands the value back, statically narrowed. `is_not_none()` strips `None`,
157
176
  `is_instance_of()` narrows to the class, and `.value` returns it with no `cast` and no bare `assert`:
158
177
 
178
+ <!-- docs-guard: skip -->
159
179
  ```python
160
180
  order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
161
181
  order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
@@ -164,6 +184,7 @@ order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
164
184
  For API tests, [`assert_conforms()`](https://solganis.github.io/assertpy2/concepts/type-safety/#contract-narrowing-with-assert_conforms) validates a raw payload against a Pydantic model and narrows the chain to it,<br>
165
185
  with `exact=True` catching silent contract drift:
166
186
 
187
+ <!-- docs-guard: skip -->
167
188
  ```python
168
189
  data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
169
190
  ```
@@ -36,9 +36,9 @@ def test_user():
36
36
  user = {"name": "Alice", "age": 30, "roles": ["viewer", "editor"]}
37
37
 
38
38
  assert_that(user).contains_key("name", "age")
39
+ assert_that(user).contains_entry({"name": "Alice"})
39
40
  assert_that(user["age"]).is_between(18, 120)
40
41
  assert_that(user["roles"]).contains("viewer").does_not_contain("admin")
41
- assert_that(user).has_name("Alice")
42
42
  ```
43
43
 
44
44
  The [full documentation](https://solganis.github.io/assertpy2/) covers every assertion, matcher, and integration.
@@ -48,6 +48,7 @@ The [full documentation](https://solganis.github.io/assertpy2/) covers every ass
48
48
  A fluent chain reads as one intent and replaces several bare asserts -<br>
49
49
  and your IDE offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/concepts/type-safety/):
50
50
 
51
+ <!-- docs-guard: skip -->
51
52
  ```python
52
53
  # bare - three statements, no autocomplete help
53
54
  assert isinstance(items, list)
@@ -71,6 +72,7 @@ E {'status': 'active'} != {'status': 'disabled'}
71
72
 
72
73
  assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs), in color:
73
74
 
75
+ <!-- docs-guard: skip -->
74
76
  ```python
75
77
  assert_that(response).is_equal_to(expected)
76
78
  ```
@@ -81,6 +83,23 @@ assert_that(response).is_equal_to(expected)
81
83
 
82
84
  The diff recurses through nested containers, and matcher predicates get the same path-level treatment. For dynamic fields like IDs or timestamps, assert a subset with [`matches_structure()`](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching).
83
85
 
86
+ Matchers are ordinary values, so they also compose inside the expected structure itself, at any depth,
87
+ with or without the fluent chain:
88
+
89
+ ```python
90
+ response = {"id": 7, "user": {"name": "Alice", "age": 30}, "tags": ["a", "b"]}
91
+
92
+ assert_that(response).is_equal_to(
93
+ {"id": match.greater_than(0), "user": {"name": "Alice", "age": match.between(18, 120)}, "tags": ["a", "b"]}
94
+ )
95
+
96
+ # or keep the bare `assert`, and pytest's own rewriting reports it
97
+ assert response == {"id": match.greater_than(0), "user": match.ignore(), "tags": ["a", "b"]}
98
+ ```
99
+
100
+ The fluent form keeps the path-level diff, the bare form keeps pytest's. There are
101
+ [39 matchers](https://solganis.github.io/assertpy2/guides/matchers/), and they combine with `&`, `|` and `~`.
102
+
84
103
  <p align="center">
85
104
  <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-gallery.png" width="640" alt="Structured diffs in the terminal: dict path, list element, set extra/missing, and structural-matcher predicate diffs, side by side">
86
105
  </p>
@@ -104,6 +123,7 @@ Works in PyCharm, VS Code, and any LSP-compatible editor.
104
123
  An assertion hands the value back, statically narrowed. `is_not_none()` strips `None`,
105
124
  `is_instance_of()` narrows to the class, and `.value` returns it with no `cast` and no bare `assert`:
106
125
 
126
+ <!-- docs-guard: skip -->
107
127
  ```python
108
128
  order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
109
129
  order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
@@ -112,6 +132,7 @@ order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
112
132
  For API tests, [`assert_conforms()`](https://solganis.github.io/assertpy2/concepts/type-safety/#contract-narrowing-with-assert_conforms) validates a raw payload against a Pydantic model and narrows the chain to it,<br>
113
133
  with `exact=True` catching silent contract drift:
114
134
 
135
+ <!-- docs-guard: skip -->
115
136
  ```python
116
137
  data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
117
138
  ```
@@ -17,7 +17,12 @@ from .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
17
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
- from .snapshot import SnapshotCreatedWarning, SnapshotUpdatedWarning, register_snapshot_serializer
20
+ from .snapshot import (
21
+ SnapshotCreatedWarning,
22
+ SnapshotKeyReusedWarning,
23
+ SnapshotUpdatedWarning,
24
+ register_snapshot_serializer,
25
+ )
21
26
 
22
27
  __all__ = [
23
28
  "AssertionFailure",
@@ -29,6 +34,7 @@ __all__ = [
29
34
  "PollSample",
30
35
  "PollTrace",
31
36
  "SnapshotCreatedWarning",
37
+ "SnapshotKeyReusedWarning",
32
38
  "SnapshotUpdatedWarning",
33
39
  "SoftAssertionCollector",
34
40
  "SyncAssertionBuilder",
@@ -14,9 +14,13 @@ express to the type checker.
14
14
  from __future__ import annotations
15
15
 
16
16
  import dataclasses
17
+ import datetime
18
+ import decimal
17
19
  import math
18
20
  import numbers
21
+ import pathlib
19
22
  import re
23
+ import uuid
20
24
  from dataclasses import dataclass
21
25
  from typing import TYPE_CHECKING
22
26
 
@@ -26,30 +30,71 @@ if TYPE_CHECKING:
26
30
  from collections.abc import Callable
27
31
 
28
32
 
33
+ _EQ_ATOMIC = frozenset(
34
+ {
35
+ int,
36
+ float,
37
+ bool,
38
+ complex,
39
+ str,
40
+ bytes,
41
+ bytearray,
42
+ type(None),
43
+ datetime.datetime,
44
+ datetime.date,
45
+ datetime.time,
46
+ datetime.timedelta,
47
+ decimal.Decimal,
48
+ uuid.UUID,
49
+ pathlib.PurePosixPath,
50
+ pathlib.PureWindowsPath,
51
+ pathlib.PosixPath,
52
+ pathlib.WindowsPath,
53
+ }
54
+ )
55
+ """Types whose ``==`` is a plain bool and which have nothing inside to walk into.
56
+
57
+ The stdlib scalars past the builtins are here for cost, not for correctness. ``strict_types`` walks
58
+ past any value it does not know to be atomic, because a container's own ``==`` says nothing about the
59
+ types inside it - and a ``datetime`` is not a container, so that walk runs the whole introspection
60
+ ladder to come back with nothing. Over 200 records of timestamps, decimals and UUIDs that was 3.7 ms
61
+ against 0.5 ms with them listed.
62
+
63
+ Listing one changes no verdict: a type difference is decided by `_types_differ()` before atomicity is
64
+ ever consulted, so ``date`` against ``datetime`` still fails. Exact types, not ``isinstance``, so a
65
+ subclass with its own structure is still walked.
66
+ """
67
+
68
+
29
69
  @dataclass(frozen=True, slots=True, kw_only=True)
30
70
  class _CompareConfig:
31
71
  """Tolerance and custom comparators for a single ``is_equal_to`` call.
32
72
 
33
73
  ``tolerance`` is an absolute tolerance applied to real-number leaves; ``comparators`` maps a ``type`` or
34
74
  an immediate field name to a ``(actual, expected) -> bool`` predicate that owns matching leaves;
35
- ``ignore_null`` skips a named field whenever the *expected* side leaves it ``None``.
75
+ ``ignore_null`` skips a named field whenever the *expected* side leaves it ``None``;
76
+ ``strict_types`` additionally requires both sides of every node to be the same type, which plain
77
+ ``==`` does not (``True == 1``, ``Decimal("1") == 1``, and so on all the way down a payload).
36
78
  """
37
79
 
38
80
  tolerance: float | None = None
39
81
  comparators: dict[object, Callable[[object, object], bool]] | None = None
40
82
  ignore_null: bool = False
83
+ strict_types: bool = False
41
84
 
42
85
 
43
- def _build_compare_config(tolerance, comparators, ignore_null=False) -> _CompareConfig | None:
44
- """Validate the ``is_equal_to`` ``tolerance``/``comparators``/``ignore_null`` kwargs and build a config.
86
+ def _build_compare_config(tolerance, comparators, ignore_null=False, strict_types=False) -> _CompareConfig | None:
87
+ """Validate the ``is_equal_to`` comparison kwargs and build a config.
45
88
 
46
89
  Returns ``None`` when none are set. ``tolerance`` must be a non-negative real number (not
47
90
  ``bool``/``complex``/``NaN``); ``comparators`` must be a dict of ``(actual, expected) -> bool`` callables
48
- keyed by ``type`` or field name; ``ignore_null`` must be a bool.
91
+ keyed by ``type`` or field name; ``ignore_null`` and ``strict_types`` must be bools.
49
92
  """
50
93
  if ignore_null is not False and ignore_null is not True:
51
94
  raise TypeError("given ignore_null arg must be a bool")
52
- if tolerance is None and comparators is None and not ignore_null:
95
+ if strict_types is not False and strict_types is not True:
96
+ raise TypeError("given strict_types arg must be a bool")
97
+ if tolerance is None and comparators is None and not ignore_null and not strict_types:
53
98
  return None
54
99
  if tolerance is not None:
55
100
  if isinstance(tolerance, bool) or not isinstance(tolerance, numbers.Number) or isinstance(tolerance, complex):
@@ -64,7 +109,9 @@ def _build_compare_config(tolerance, comparators, ignore_null=False) -> _Compare
64
109
  for comparator in comparators.values():
65
110
  if not callable(comparator):
66
111
  raise TypeError("each comparator must be callable")
67
- return _CompareConfig(tolerance=tolerance, comparators=comparators, ignore_null=ignore_null)
112
+ return _CompareConfig(
113
+ tolerance=tolerance, comparators=comparators, ignore_null=ignore_null, strict_types=strict_types
114
+ )
68
115
 
69
116
 
70
117
  def _ambiguous_array_operand(value: object, other: object) -> object | None:
@@ -208,13 +255,35 @@ def _resolve_comparator(actual, config: _CompareConfig, *, field):
208
255
  return None
209
256
 
210
257
 
258
+ def _types_differ(actual, expected) -> bool:
259
+ """Whether ``strict_types`` should reject this pair.
260
+
261
+ A matcher standing in for a value is not a value, so it is exempt: the expected side of
262
+ ``is_equal_to({"id": match.greater_than(0)})`` is a ``GreaterThanMatcher`` by construction, and
263
+ comparing its type against an ``int`` would break every composed matcher rather than catch a bug.
264
+
265
+ ``_is_matcher`` is imported here rather than at module scope because ``_matcher_impls`` imports
266
+ ``_guarded_not_equal`` from this module, so the module-level import would be a cycle.
267
+ """
268
+ if type(actual) is type(expected):
269
+ return False
270
+ from .._matcher_impls import _is_matcher
271
+
272
+ return not _is_matcher(expected) and not _is_matcher(actual)
273
+
274
+
211
275
  def _node_decision(actual, expected, config: _CompareConfig | None, *, field=None) -> str:
212
- """Classify a node as ``"equal"``, ``"leaf"`` or ``"recurse"``.
276
+ """Classify a node as ``"equal"``, ``"leaf"``, ``"recurse"`` or ``"strict"``.
213
277
 
214
278
  With ``config is None`` this is exactly the engine's historical behavior: differing values ``"recurse"``
215
279
  (to decompose into a sub-diff), equal values are ``"equal"`` (skipped); ``"leaf"`` never occurs. With a
216
280
  config, a matching comparator or tolerance owns the node - it is classified ``"equal"`` or ``"leaf"`` and
217
281
  never recursed into.
282
+
283
+ ``"strict"`` is the fourth: the two sides are equal and the same type, but ``strict_types`` still has
284
+ to look inside, because a container's ``==`` says nothing about the types of its members. It differs
285
+ from ``"recurse"`` only in what an undecomposable value means, which
286
+ `assertpy2._engine._diff._child_entries()` is the single place to know.
218
287
  """
219
288
  if config is not None:
220
289
  if config.ignore_null and field is not None and expected is None:
@@ -222,6 +291,24 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
222
291
  comparator = _resolve_comparator(actual, config, field=field)
223
292
  if comparator is not None:
224
293
  return "equal" if comparator(actual, expected) else "leaf"
294
+ if config.strict_types:
295
+ if actual is expected:
296
+ # what `PyObject_RichCompareBool` hands a container for free, and what forcing the walk
297
+ # below would otherwise take away: one object is equal to itself without consulting
298
+ # `__eq__`, which is why a shared subnode is cheap and why `[nan] == [nan]` is true when
299
+ # both elements are the same float
300
+ return "equal"
301
+ if _types_differ(actual, expected):
302
+ # ahead of tolerance on purpose: a tolerance says how far apart two numbers may be, it
303
+ # does not say they may be different types, and a strict run that quietly accepted int
304
+ # vs float inside its own tolerance would be the surprise, not the rule
305
+ return "leaf"
306
+ if type(actual) not in _EQ_ATOMIC and not _guarded_not_equal(actual, expected):
307
+ # a container's own `==` says nothing about the types inside it: `[True] == [1]`. The
308
+ # walk normally stops here, so under strict types it has to keep going. Whether the
309
+ # walker can actually take this value apart is not predicted here: `"strict"` tells the
310
+ # caller that a value it cannot decompose is one that is already equal
311
+ return "strict"
225
312
  if config.tolerance is not None and _is_real_number(actual) and _is_real_number(expected):
226
313
  return "equal" if _within_tolerance(actual, expected, config.tolerance) else "leaf"
227
314
  return "recurse" if _guarded_not_equal(actual, expected) else "equal"