assertpy2 2.23.0__tar.gz → 2.24.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 (233) hide show
  1. {assertpy2-2.23.0 → assertpy2-2.24.0}/CONTRIBUTING.md +54 -11
  2. {assertpy2-2.23.0 → assertpy2-2.24.0}/PKG-INFO +4 -4
  3. {assertpy2-2.23.0 → assertpy2-2.24.0}/README.md +3 -3
  4. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_clustering.py +7 -13
  5. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_dangling.py +156 -53
  6. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_builder_check_typing.py +82 -114
  7. assertpy2-2.24.0/assertpy2/_engine/_capable_typing.py +402 -0
  8. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_check_typing.py +37 -13
  9. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_compare.py +10 -13
  10. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_compat.py +14 -1
  11. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_diff.py +5 -11
  12. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_equality.py +3 -6
  13. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_introspection.py +45 -2
  14. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_membership.py +29 -9
  15. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_mixin_base.py +19 -7
  16. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_operations.py +4 -11
  17. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_ordering.py +5 -9
  18. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_poll_typing.py +165 -229
  19. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_require.py +29 -0
  20. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_size.py +1 -2
  21. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_typing.py +77 -49
  22. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_hints.py +10 -21
  23. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_matcher_impls.py +70 -59
  24. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_satisfies.py +15 -17
  25. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_snapshot_codec.py +4 -2
  26. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/assertpy.py +110 -91
  27. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/async_assertions.py +10 -18
  28. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/base.py +22 -15
  29. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/behave_matchers.py +16 -10
  30. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/collection.py +17 -27
  31. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/contains.py +4 -5
  32. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/dict.py +14 -11
  33. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/dynamic.py +1 -2
  34. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/errors.py +26 -4
  35. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/exception.py +4 -3
  36. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/extracting.py +19 -18
  37. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/file.py +7 -4
  38. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/helpers.py +6 -11
  39. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/http_mixin.py +1 -2
  40. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/json_mixin.py +3 -2
  41. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/matchers.py +6 -6
  42. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/numeric.py +8 -5
  43. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/pytest_plugin.py +170 -117
  44. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/snapshot.py +42 -31
  45. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/string.py +1 -2
  46. {assertpy2-2.23.0 → assertpy2-2.24.0}/benchmarks/test_perf.py +140 -41
  47. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/concepts/stability.md +60 -39
  48. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/concepts/type-safety.md +100 -78
  49. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/extending/custom-assertions.md +7 -6
  50. assertpy2-2.24.0/docs/getting-started/comparison.md +280 -0
  51. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/getting-started/migration.md +1 -1
  52. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/assertions.md +66 -23
  53. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/data.md +20 -11
  54. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/errors.md +99 -60
  55. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/matchers.md +8 -7
  56. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/testing.md +23 -13
  57. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/recipes.md +5 -3
  58. {assertpy2-2.23.0 → assertpy2-2.24.0}/pyproject.toml +30 -2
  59. {assertpy2-2.23.0 → assertpy2-2.24.0}/scripts/generate_check_protocols.py +9 -9
  60. assertpy2-2.24.0/scripts/generate_poll_protocols.py +863 -0
  61. {assertpy2-2.23.0 → assertpy2-2.24.0}/scripts/mutation_report.py +12 -6
  62. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/api_snapshot.json +152 -144
  63. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/api_surface.py +2 -4
  64. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/conftest.py +50 -0
  65. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/docs_fixtures.py +37 -13
  66. assertpy2-2.24.0/tests/pyright_baseline.py +108 -0
  67. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_api_compatibility.py +42 -39
  68. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_api_vocabulary.py +5 -10
  69. assertpy2-2.24.0/tests/test_architecture_doc.py +133 -0
  70. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_async.py +13 -26
  71. assertpy2-2.24.0/tests/test_async_predicates.py +157 -0
  72. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_bytes.py +2 -4
  73. assertpy2-2.24.0/tests/test_capable_protocol.py +578 -0
  74. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_check.py +3 -6
  75. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_check_protocols.py +41 -19
  76. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_class.py +35 -0
  77. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_clustering.py +31 -37
  78. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_collection.py +9 -14
  79. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_core.py +5 -12
  80. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_custom_dict.py +2 -5
  81. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dangling.py +134 -20
  82. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dataframe.py +3 -7
  83. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_datetime.py +1 -2
  84. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dict.py +1 -2
  85. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dict_compare.py +1 -2
  86. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_docs_examples.py +45 -13
  87. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_docs_typing.py +4 -6
  88. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dynamic.py +1 -2
  89. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_equals.py +7 -15
  90. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_errors.py +2 -5
  91. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_evaluation_core.py +107 -19
  92. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_exception_context.py +1 -2
  93. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_expected_contract.py +2 -4
  94. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_expected_exception.py +4 -8
  95. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_expected_warning.py +1 -2
  96. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_extensions.py +17 -11
  97. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_extracting.py +3 -6
  98. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_hints.py +7 -15
  99. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_http.py +6 -12
  100. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_inline_record.py +1 -2
  101. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_inline_snapshot.py +4 -8
  102. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_iterable_cluster.py +8 -8
  103. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_json.py +1 -2
  104. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_list.py +4 -8
  105. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_matcher_parity.py +49 -30
  106. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_matcher_registry.py +2 -5
  107. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_matchers.py +25 -12
  108. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_message_elision.py +2 -3
  109. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_not.py +4 -7
  110. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_openapi_contract.py +6 -2
  111. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_operation_contract.py +6 -12
  112. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_optional_integration_contracts.py +1 -2
  113. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_overload_order.py +2 -7
  114. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_pipeline.py +8 -9
  115. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_poll_protocols.py +42 -23
  116. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_property_attrs.py +2 -4
  117. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_property_based.py +143 -47
  118. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_protocol_parity.py +33 -52
  119. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_public_surface.py +48 -24
  120. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_pyright_baseline.py +45 -21
  121. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_pytest_plugin.py +147 -24
  122. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_compare.py +1 -2
  123. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_compare_config.py +46 -44
  124. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_refusals.py +30 -13
  125. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_rich_diff.py +17 -33
  126. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_satisfy.py +2 -4
  127. assertpy2-2.24.0/tests/test_skip_guard.py +115 -0
  128. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_snapshots.py +99 -15
  129. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_soft.py +6 -12
  130. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_structural.py +10 -19
  131. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_surface_conformance.py +23 -28
  132. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_sync_eventually.py +1 -2
  133. assertpy2-2.24.0/tests/test_type_expression_failures.py +148 -0
  134. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing.py +70 -38
  135. assertpy2-2.24.0/tests/test_typing_claims.py +194 -0
  136. assertpy2-2.24.0/tests/test_typing_completeness.py +142 -0
  137. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_conformance.py +64 -15
  138. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_from_a_wheel.py +5 -9
  139. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_http.py +2 -2
  140. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_integrations.py +3 -4
  141. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_negative.py +19 -24
  142. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_vacuity_contract.py +9 -12
  143. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_cases.py +84 -31
  144. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_harness.py +28 -1
  145. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_http.py +7 -7
  146. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_integrations.py +7 -8
  147. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_integrations_baseline.py +3 -4
  148. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_negative_baseline.py +77 -43
  149. assertpy2-2.23.0/docs/getting-started/comparison.md +0 -248
  150. assertpy2-2.23.0/scripts/generate_poll_protocols.py +0 -448
  151. assertpy2-2.23.0/tests/pyright_baseline.py +0 -66
  152. assertpy2-2.23.0/tests/test_typing_claims.py +0 -80
  153. {assertpy2-2.23.0 → assertpy2-2.24.0}/.gitignore +0 -0
  154. {assertpy2-2.23.0 → assertpy2-2.24.0}/LICENSE +0 -0
  155. {assertpy2-2.23.0 → assertpy2-2.24.0}/SECURITY.md +0 -0
  156. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/__init__.py +0 -0
  157. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/__init__.py +0 -0
  158. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_contract.py +0 -0
  159. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_path.py +0 -0
  160. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_text.py +0 -0
  161. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_inline.py +0 -0
  162. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/bytes_mixin.py +0 -0
  163. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/dataframe.py +0 -0
  164. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/date.py +0 -0
  165. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/outcome.py +0 -0
  166. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/py.typed +0 -0
  167. {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/warning.py +0 -0
  168. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-equal.png +0 -0
  169. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-equal.svg +0 -0
  170. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-gallery.png +0 -0
  171. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-match.svg +0 -0
  172. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-sequence.svg +0 -0
  173. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-set.svg +0 -0
  174. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-string.svg +0 -0
  175. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/extending/integrations.md +0 -0
  176. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/getting-started/quickstart.md +0 -0
  177. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/fluent.md +0 -0
  178. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/index.md +0 -0
  179. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/logo-dark.svg +0 -0
  180. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/logo.svg +0 -0
  181. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/async.md +0 -0
  182. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/bytes.md +0 -0
  183. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/collections.md +0 -0
  184. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/containment.md +0 -0
  185. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/core.md +0 -0
  186. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/dataframes.md +0 -0
  187. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/dates.md +0 -0
  188. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/dicts.md +0 -0
  189. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/dynamic.md +0 -0
  190. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/entry-points.md +0 -0
  191. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/errors.md +0 -0
  192. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/exceptions.md +0 -0
  193. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/extracting.md +0 -0
  194. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/files.md +0 -0
  195. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/http.md +0 -0
  196. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/json.md +0 -0
  197. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/matchers.md +0 -0
  198. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/numbers.md +0 -0
  199. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/overview.md +0 -0
  200. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/snapshots.md +0 -0
  201. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/strings.md +0 -0
  202. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/warnings.md +0 -0
  203. {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/stylesheets/extra.css +0 -0
  204. {assertpy2-2.23.0 → assertpy2-2.24.0}/mkdocs.yml +0 -0
  205. {assertpy2-2.23.0 → assertpy2-2.24.0}/mkdocs_hooks.py +0 -0
  206. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/group_compat.py +0 -0
  207. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_attrs_support.py +0 -0
  208. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_behave_matchers.py +0 -0
  209. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_bool.py +0 -0
  210. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_boundary_cases.py +0 -0
  211. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_callable.py +0 -0
  212. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_chaining.py +0 -0
  213. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_custom_list.py +0 -0
  214. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_description.py +0 -0
  215. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_fail.py +0 -0
  216. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_file.py +0 -0
  217. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_grouped_soft.py +0 -0
  218. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_in.py +0 -0
  219. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_match_result.py +0 -0
  220. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_namedtuple.py +0 -0
  221. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_none.py +0 -0
  222. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_numbers.py +0 -0
  223. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_overloads.py +0 -0
  224. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_assertion.py +0 -0
  225. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_compare_attrs.py +0 -0
  226. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_compare_pydantic.py +0 -0
  227. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_regex_groups.py +0 -0
  228. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_same_as.py +0 -0
  229. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_soft_fail.py +0 -0
  230. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_string.py +0 -0
  231. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_traceback.py +0 -0
  232. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_type.py +0 -0
  233. {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_warn.py +0 -0
@@ -23,17 +23,26 @@ Read more about how pulls work on GitHub's [About pull requests](https://help.gi
23
23
  - Python 3.10+
24
24
  - [uv](https://docs.astral.sh/uv/) as the package manager
25
25
 
26
+ ## Before you change a public signature
27
+
28
+ Read [ARCHITECTURE.md](ARCHITECTURE.md). Four files under `assertpy2/_engine/` are generated, the public
29
+ surface is pinned by a recorded snapshot, and neither is discoverable from the files themselves.
30
+
26
31
  ## Verification pipeline
27
32
 
28
33
  Run all checks before submitting a PR. Every step must pass.
29
34
 
30
- Install exactly this set. A bare `uv sync` leaves out the optional libraries several tests import, and
31
- those tests fail rather than skip:
35
+ ### Install
36
+
37
+ A bare `uv sync` leaves out the optional libraries several tests import, and those tests then fail
38
+ rather than skip. Install exactly this set:
32
39
 
33
40
  ```bash
34
41
  uv sync --extra json --extra data --extra inline --group integrations --group docs-examples
35
42
  ```
36
43
 
44
+ ### The main gate
45
+
37
46
  ```bash
38
47
  uv run ruff check .
39
48
  uv run ruff format --check .
@@ -43,22 +52,58 @@ uv run pytest tests/test_docs_examples.py tests/test_typing_claims.py
43
52
  uv run --group docs mkdocs build --strict
44
53
  ```
45
54
 
46
- The public typing surface is checked by three checkers. Run them after the block above, not before:
47
- `uv run --group typecheck` adds the checkers to the same `.venv` and leaves them there, and a coverage
48
- run with them installed collects a different set:
55
+ CI requires 100% code coverage.
56
+
57
+ ### Then once on the supported floor
58
+
59
+ Run the suite on Python 3.10 as well. This is not optional, and the block above does not cover it.
60
+
61
+ A union carries `__name__` from 3.14 onwards and not before. `logging.LoggerAdapter` became
62
+ subscriptable in 3.11. Neither difference is visible to a type checker told to target 3.10, and both
63
+ shipped as defects that every other gate passed.
64
+
65
+ Use a throwaway environment. `uv run --python 3.10` inside the project rebuilds `.venv` for that
66
+ version, and on Windows it can fail partway and leave you without pytest:
67
+
68
+ ```bash
69
+ uv venv /tmp/py310 --python 3.10
70
+
71
+ export VIRTUAL_ENV=/tmp/py310
72
+ uv pip install -e ".[json,data,inline]"
73
+ uv pip install pytest pytest-cov hypothesis ruff
74
+ uv pip install attrs pydantic requests httpx flask
75
+
76
+ /tmp/py310/bin/python -m pytest tests --ignore=tests/test_docs_examples.py -q
77
+ ```
78
+
79
+ `ruff` belongs in that list: the tests that compare a generated file against its generator shell out
80
+ to it, and without it they fail for a reason that has nothing to do with your change.
81
+
82
+ ### The four type checkers
83
+
84
+ Run these after the main gate, not before. `uv run --group typecheck` adds the checkers to the same
85
+ `.venv` and leaves them there, and a coverage run with them installed collects a different set:
49
86
 
50
87
  ```bash
51
88
  uv run --group typecheck mypy --strict --follow-imports=silent tests/test_typing.py
52
- uv run --group typecheck pyright --pythonversion 3.14 tests/test_typing.py
89
+ PYRIGHT_PYTHON_FORCE_VERSION=1.1.413 uv run --group typecheck pyright --pythonversion 3.14 tests/test_typing.py
90
+ uv run --group typecheck pyrefly check tests/test_typing.py
53
91
  uv run --group typecheck pytest tests/test_pyright_baseline.py
54
92
  ```
55
93
 
56
- `--pythonversion 3.14` is not decoration. Pyright reports against the interpreter it finds unless told
57
- otherwise, and the count moves with it: 108 diagnostics for this package on 3.10 against 102 on 3.14.
94
+ `PYRIGHT_PYTHON_FORCE_VERSION` picks the engine rather than the launcher. The `pyright` distribution on
95
+ PyPI stopped at 1.1.411 on 25 June while npm has shipped 1.1.412 and 1.1.413 since, so the launcher and
96
+ the engine move apart. The baseline is recorded against one engine, and `tests/typing_harness.py` pins
97
+ the same build, so only this direct invocation needs the variable.
98
+
99
+ `--pythonversion 3.14` is not decoration. Pyright reports against the interpreter it finds unless
100
+ told otherwise, and the count moves with it: 174 diagnostics for this package on 3.10 against 169 on
101
+ 3.14.
102
+
58
103
  Without it a contributor on the supported floor meets a red baseline that says nothing about their
59
104
  change. The baseline test passes the same target itself, so it gives one answer everywhere.
60
105
 
61
- Three details that cost time if you meet them the hard way:
106
+ ### Three details that cost time if you meet them the hard way
62
107
 
63
108
  - `ty check` is scoped on purpose. Run over the whole tree it reports hundreds of diagnostics from
64
109
  `tests/`, which is full of deliberate negative typing cases: calls that must not type-check.
@@ -68,8 +113,6 @@ Three details that cost time if you meet them the hard way:
68
113
  - `tests/test_docs_examples.py` is run separately because it executes the snippets in `docs/`, which
69
114
  needs the `docs-examples` group and a different collection.
70
115
 
71
- CI requires 100% code coverage.
72
-
73
116
  ## Commit style
74
117
 
75
118
  Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, etc.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: assertpy2
3
- Version: 2.23.0
3
+ Version: 2.24.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
@@ -68,7 +68,7 @@ Description-Content-Type: text/markdown
68
68
  <a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
69
69
  <a href="https://pepy.tech/projects/assertpy2"><img src="https://static.pepy.tech/badge/assertpy2/month" alt="Downloads"></a>
70
70
  <br>
71
- <a href="https://solganis.github.io/assertpy2/concepts/type-safety/"><img src="https://img.shields.io/badge/type--checked-ty%20%7C%20mypy%20%7C%20pyright-2ea043" alt="public overloads type-checked by ty, mypy --strict, and pyright with zero suppressions"></a>
71
+ <a href="https://solganis.github.io/assertpy2/concepts/type-safety/"><img src="https://img.shields.io/badge/overloads-ty%20%7C%20mypy%20%7C%20pyright%20%7C%20pyrefly-2ea043" alt="every public overload checked by ty, mypy --strict, pyright and pyrefly with zero suppressions"></a>
72
72
  <a href="https://solganis.github.io/assertpy2/"><img src="https://img.shields.io/badge/docs-online-black" alt="Documentation"></a>
73
73
  <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://img.shields.io/ossf-scorecard/github.com/Solganis/assertpy2?label=OpenSSF%20Scorecard" alt="OpenSSF Scorecard"></a>
74
74
  </p>
@@ -201,7 +201,7 @@ There are [45 matchers](https://solganis.github.io/assertpy2/guides/matchers/),
201
201
  <h2 align="center"><a href="https://solganis.github.io/assertpy2/concepts/type-safety/">Type-aware autocomplete</a></h2>
202
202
 
203
203
  `assert_that()` uses `@overload` to return type-specific Protocols.<br>
204
- Your IDE shows only methods relevant to the value you're testing, not all 100+:
204
+ Your IDE shows only methods relevant to the value you're testing, not the whole surface:
205
205
 
206
206
  - `assert_that("hello").` &rarr; string methods: `starts_with`, `matches`, `is_alpha`, ...
207
207
  - `assert_that(42).` &rarr; numeric methods: `is_positive`, `is_between`, `is_close_to`, ...
@@ -221,7 +221,7 @@ An assertion hands the value back, statically narrowed.
221
221
  <!-- docs-guard: skip -->
222
222
  ```python
223
223
  order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
224
- order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
224
+ order.refund() # statically PaidOrder - verified by ty, mypy, pyright and pyrefly
225
225
  ```
226
226
 
227
227
  For API tests,
@@ -16,7 +16,7 @@
16
16
  <a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
17
17
  <a href="https://pepy.tech/projects/assertpy2"><img src="https://static.pepy.tech/badge/assertpy2/month" alt="Downloads"></a>
18
18
  <br>
19
- <a href="https://solganis.github.io/assertpy2/concepts/type-safety/"><img src="https://img.shields.io/badge/type--checked-ty%20%7C%20mypy%20%7C%20pyright-2ea043" alt="public overloads type-checked by ty, mypy --strict, and pyright with zero suppressions"></a>
19
+ <a href="https://solganis.github.io/assertpy2/concepts/type-safety/"><img src="https://img.shields.io/badge/overloads-ty%20%7C%20mypy%20%7C%20pyright%20%7C%20pyrefly-2ea043" alt="every public overload checked by ty, mypy --strict, pyright and pyrefly with zero suppressions"></a>
20
20
  <a href="https://solganis.github.io/assertpy2/"><img src="https://img.shields.io/badge/docs-online-black" alt="Documentation"></a>
21
21
  <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://img.shields.io/ossf-scorecard/github.com/Solganis/assertpy2?label=OpenSSF%20Scorecard" alt="OpenSSF Scorecard"></a>
22
22
  </p>
@@ -149,7 +149,7 @@ There are [45 matchers](https://solganis.github.io/assertpy2/guides/matchers/),
149
149
  <h2 align="center"><a href="https://solganis.github.io/assertpy2/concepts/type-safety/">Type-aware autocomplete</a></h2>
150
150
 
151
151
  `assert_that()` uses `@overload` to return type-specific Protocols.<br>
152
- Your IDE shows only methods relevant to the value you're testing, not all 100+:
152
+ Your IDE shows only methods relevant to the value you're testing, not the whole surface:
153
153
 
154
154
  - `assert_that("hello").` &rarr; string methods: `starts_with`, `matches`, `is_alpha`, ...
155
155
  - `assert_that(42).` &rarr; numeric methods: `is_positive`, `is_between`, `is_close_to`, ...
@@ -169,7 +169,7 @@ An assertion hands the value back, statically narrowed.
169
169
  <!-- docs-guard: skip -->
170
170
  ```python
171
171
  order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
172
- order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
172
+ order.refund() # statically PaidOrder - verified by ty, mypy, pyright and pyrefly
173
173
  ```
174
174
 
175
175
  For API tests,
@@ -188,8 +188,7 @@ def stable_repr(value: object, _seen: frozenset[int] = frozenset()) -> str:
188
188
  try:
189
189
  if isinstance(value, (set, frozenset)):
190
190
  members = sorted(stable_repr(member, inner) for member in value)
191
- # an empty one falls back to `repr`, since `{}` is a dict, so `set()` and the `frozenset()` it equals
192
- # read apart
191
+ # an empty one falls back to `repr`: `{}` is a dict, so `set()` and the `frozenset()` it equals read apart
193
192
  return "{" + ", ".join(members) + "}" if value else _safe_repr(value)
194
193
  if isinstance(value, dict):
195
194
  pairs = (f"{stable_repr(key, inner)}: {stable_repr(item, inner)}" for key, item in value.items())
@@ -287,8 +286,7 @@ def is_well_formed(key: Signature) -> bool:
287
286
  )
288
287
  if key.steps:
289
288
  return False
290
- # exactly one of the two: a pair of values only for the kinds whose fields hold them, or a `contains` payload
291
- # would print the `None` standing for a missing item
289
+ # a pair of values only for the kinds whose fields hold them, or a `contains` payload prints a `None`
292
290
  if key.label:
293
291
  return not key.values
294
292
  return len(key.values) == 2 and key.where in _VALUES_ARE_VALUES
@@ -327,8 +325,7 @@ def clusters(
327
325
  """
328
326
  if total_failures <= 0:
329
327
  return []
330
- # keyed on the node id: `pytest-rerunfailures` reports one test failing per retry, and counting attempts printed
331
- # a cluster of six over two tests
328
+ # keyed on the node id: `pytest-rerunfailures` retries printed a cluster of six over two tests
332
329
  grouped: dict[Signature, dict[str, None]] = {}
333
330
  actuals: dict[Signature, dict[str, None]] = {}
334
331
  expecteds: dict[Signature, dict[str, None]] = {}
@@ -473,21 +470,18 @@ def render(
473
470
  lines.append(f"{collect_errors} collection error{'' if one else 's'}, not counted below")
474
471
  for cluster, where in zip(shown, _headings(shown), strict=True):
475
472
  lines.append(_headline(cluster, where, total_failures))
476
- # a cluster keyed on its diagnostic already says the difference in words, and the values under
477
- # it are the ones that vary between its failures rather than the thing they share
473
+ # a cluster keyed on its diagnostic says the difference in words; the values are what varies inside it
478
474
  if cluster.signature.label:
479
475
  lines.append(f" {cluster.signature.label}")
480
476
  else:
481
477
  lines.append(f" actual: {_side(cluster.actuals)}")
482
478
  lines.append(f" expected: {_side(cluster.expecteds)}")
483
479
  if omitted:
484
- # said rather than dropped: five of eleven causes shown silently reads as "these are the
485
- # causes". "more" rather than "smaller", since equal-sized clusters are ordered by signature
480
+ # said rather than dropped: "five of eleven" silently reads as "these are the causes". "more" rather
481
+ # than "smaller", since equal-sized clusters are ordered by signature
486
482
  lines.append(f"{len(omitted)} more cluster{'' if len(omitted) == 1 else 's'} not shown")
487
483
  covered = len({nodeid for cluster in found for nodeid in cluster.nodeids})
488
484
  if covered < total_failures:
489
- # what the number measures, rather than "not clustered": two tests that share a difference under
490
- # a floor of three are related, and the summary declined to print them, which is not the same as
491
- # having found nothing about them
485
+ # what the number measures, not "not clustered": the summary declined to print a pair under the floor
492
486
  lines.append(f"{total_failures - covered} of {total_failures} outside any cluster of {minimum}")
493
487
  return lines
@@ -40,7 +40,7 @@ from __future__ import annotations
40
40
  import ast
41
41
  import io
42
42
  import tokenize
43
- from typing import TYPE_CHECKING, Final, NamedTuple
43
+ from typing import Final, NamedTuple
44
44
 
45
45
  from ._engine._operations import (
46
46
  ALSO_ASSERTS,
@@ -52,9 +52,6 @@ from ._engine._operations import (
52
52
  WITHOUT_A_VERDICT,
53
53
  )
54
54
 
55
- if TYPE_CHECKING:
56
- from collections.abc import Iterator
57
-
58
55
  __tracebackhide__ = True
59
56
 
60
57
  # the builder factories and only those: everything else either asserts by itself or is not an entry point
@@ -128,33 +125,67 @@ class _Bindings(NamedTuple):
128
125
  return bool(self.direct or self.modules)
129
126
 
130
127
 
131
- def _rebound(tree: ast.Module) -> frozenset[str]:
132
- """Names this module binds itself, whatever it also imported under them.
128
+ class _Survey(NamedTuple):
129
+ """Everything the checks need out of the tree, gathered in one walk.
130
+
131
+ Six separate walks were measured before this: resolving the entry points, the names the module
132
+ rebinds, the same two again for `pytest`, the blocks that demand a raise, and the statements that
133
+ consume a builder. Each is cheap on its own and the tree is walked whole every time, which came to
134
+ 624 ms over this repository's own 97 test modules against 166 ms of parsing.
135
+
136
+ `statements` carries the scope each one sits in, as the chain of names around it. A chain rather
137
+ than one name because a bare name does not identify a test: two classes in one file may each define
138
+ `test_same`, and matching on the name alone handed both findings to whichever ran first.
139
+ """
140
+
141
+ rebound: frozenset[str]
142
+ imports: tuple[ast.Import | ast.ImportFrom, ...]
143
+ blocks: tuple[ast.With | ast.AsyncWith, ...]
144
+ statements: tuple[tuple[ast.Expr | ast.Assert, tuple[str, ...]], ...]
145
+
146
+
147
+ def _survey(tree: ast.Module) -> _Survey:
148
+ """Walk once, keeping the four things the checks ask about.
133
149
 
134
- A fixture parameter named `assert_that`, a local `assert_that = lambda ...`, a module-level
135
- rebinding: in each of those the call in the body is not this library's, and reporting it is a false
136
- alarm on somebody else's function.
150
+ The walk is its own rather than `ast.walk` because the scope chain has to be carried down, and
151
+ `ast.walk` hands back a flat stream with no way to know what encloses what.
137
152
 
138
- Deliberately module-wide rather than scope-aware. One shadowed parameter disables the check for
139
- that name across the file, which costs findings and cannot invent them, and the alternative is a
140
- scope tracker inside what is meant to stay a small static pass.
153
+ Two statements consume a builder on the spot. An expression statement discards the value, and
154
+ anywhere else it is bound, passed or returned, where whether *that* asserts is not a question about
155
+ this statement. An `assert` reads it for its truth, which a builder and a bound method answer the
156
+ same way whatever the value is.
141
157
  """
142
- names: set[str] = set()
143
- for node in ast.walk(tree):
144
- if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
145
- names.add(node.id)
146
- elif isinstance(node, ast.arg):
147
- names.add(node.arg)
148
- # merging the identical branches is what ruff asks for and a type checker then rejects, since only the
149
- # separate branch narrows `ExceptHandler.name`
150
- elif isinstance(node, ast.ExceptHandler) and node.name: # noqa: SIM114
151
- names.add(node.name)
152
- elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
153
- names.add(node.name)
154
- return frozenset(names)
155
-
156
-
157
- def _bindings(tree: ast.Module, extra: frozenset[str] = frozenset()) -> _Bindings:
158
+ rebound: set[str] = set()
159
+ imports: list[ast.Import | ast.ImportFrom] = []
160
+ blocks: list[ast.With | ast.AsyncWith] = []
161
+ statements: list[tuple[ast.Expr | ast.Assert, tuple[str, ...]]] = []
162
+ stack: list[tuple[ast.AST, tuple[str, ...]]] = [(tree, ())]
163
+ while stack:
164
+ node, scope = stack.pop()
165
+ for child in ast.iter_child_nodes(node):
166
+ inner = scope
167
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
168
+ rebound.add(child.name)
169
+ inner = (*scope, child.name)
170
+ elif isinstance(child, ast.Expr | ast.Assert):
171
+ statements.append((child, scope))
172
+ elif isinstance(child, ast.Name):
173
+ if isinstance(child.ctx, ast.Store):
174
+ rebound.add(child.id)
175
+ elif isinstance(child, ast.arg):
176
+ rebound.add(child.arg)
177
+ # merging the branches is what ruff asks and a checker rejects: only the separate one narrows the name
178
+ elif isinstance(child, ast.ExceptHandler) and child.name:
179
+ rebound.add(child.name)
180
+ elif isinstance(child, (ast.Import, ast.ImportFrom)):
181
+ imports.append(child)
182
+ elif isinstance(child, (ast.With, ast.AsyncWith)):
183
+ blocks.append(child)
184
+ stack.append((child, inner))
185
+ return _Survey(frozenset(rebound), tuple(imports), tuple(blocks), tuple(statements))
186
+
187
+
188
+ def _bindings(survey: _Survey, extra: frozenset[str] = frozenset()) -> _Bindings:
158
189
  """Resolve how this module spells the entry points, `as` aliases and star imports included.
159
190
 
160
191
  A module that never imports one is skipped whole: a user function of their own named
@@ -168,7 +199,7 @@ def _bindings(tree: ast.Module, extra: frozenset[str] = frozenset()) -> _Binding
168
199
  """
169
200
  direct: set[str] = set()
170
201
  modules: set[str] = set()
171
- for node in ast.walk(tree):
202
+ for node in survey.imports:
172
203
  if isinstance(node, ast.ImportFrom):
173
204
  ours = (node.module or "").split(".")[0] == _PACKAGE
174
205
  for alias in node.names:
@@ -176,11 +207,11 @@ def _bindings(tree: ast.Module, extra: frozenset[str] = frozenset()) -> _Binding
176
207
  direct |= _ENTRY # the docs' own preamble; a star import binds every entry point
177
208
  elif alias.name in extra or (ours and alias.name in _ENTRY):
178
209
  direct.add(alias.asname or alias.name)
179
- elif isinstance(node, ast.Import):
210
+ else:
180
211
  for alias in node.names:
181
212
  if alias.name.split(".")[0] == _PACKAGE:
182
213
  modules.add(alias.asname or alias.name.split(".")[0])
183
- shadowed = _rebound(tree)
214
+ shadowed = survey.rebound
184
215
  return _Bindings(frozenset(direct) - shadowed, frozenset(modules) - shadowed)
185
216
 
186
217
 
@@ -287,28 +318,92 @@ def _closed(node: ast.expr) -> bool:
287
318
  )
288
319
 
289
320
 
290
- def _statements(tree: ast.Module) -> Iterator[tuple[ast.Expr | ast.Assert, tuple[str, ...]]]:
291
- """Every statement that consumes a builder on the spot, with the scope it sits in.
321
+ _DEMANDS_A_RAISE: Final = frozenset({"raises", "RaisesGroup"})
322
+ """The `pytest` blocks that fail when their body reaches the end without raising.
292
323
 
293
- Two do. An expression statement discards the value, and anywhere else it is bound, passed or
294
- returned, where whether *that* asserts is not a question about this statement. An `assert` reads
295
- it for its truth, which a builder and a bound method answer the same way whatever the value is.
324
+ `warns` and `deprecated_call` are deliberately not here. They expect their body to finish normally,
325
+ so a chain that asserts nothing inside one is as silent as it would be anywhere else. Measured: a
326
+ `pytest.warns` block whose body warns and then dangles passes.
327
+ """
328
+
329
+
330
+ def _imported_as(survey: _Survey, package: str, wanted: frozenset[str]) -> tuple[frozenset[str], frozenset[str]]:
331
+ """How this module spells names from *package*: bare after an import, and dotted through it.
332
+
333
+ Resolved rather than matched on the bare name, for the reason `_bindings` resolves this library's
334
+ own: a project helper that happens to be called `raises` or `fail` is not this one, and trusting
335
+ the name alone would silence a real finding inside it.
336
+
337
+ Three ways a spelling stops being the package's, all of them dropped:
338
+
339
+ * the module rebinds it, which the survey's `rebound` set answers
340
+ * another import binds the same spelling, where which one wins is the order they are written in
341
+ rather than anything a name can be read for
342
+ * it comes from somewhere else entirely
296
343
 
297
- The scope is a chain rather than one name because a bare name does not identify a test: two classes
298
- in one file may each define `test_same`, and matching on the name alone handed both findings to
299
- whichever ran first. Classes are walked for the same reason, so the chain reads
300
- ``("TestOne", "test_same")``.
344
+ What this cannot answer is a mutation at run time. `pytest.raises = something_else` leaves the
345
+ import reading exactly as it does here, and no static pass sees past that. The boundary is the
346
+ spelling, not the object it will hold.
301
347
  """
302
- stack: list[tuple[ast.AST, tuple[str, ...]]] = [(tree, ())]
303
- while stack:
304
- node, scope = stack.pop()
305
- for child in ast.iter_child_nodes(node):
306
- if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
307
- stack.append((child, (*scope, child.name)))
308
- else:
309
- if isinstance(child, ast.Expr | ast.Assert):
310
- yield child, scope
311
- stack.append((child, scope))
348
+ direct: set[str] = set()
349
+ modules: set[str] = set()
350
+ elsewhere: set[str] = set()
351
+ for node in survey.imports:
352
+ if isinstance(node, ast.ImportFrom):
353
+ ours = (node.module or "").split(".")[0] == package
354
+ for alias in node.names:
355
+ bound = alias.asname or alias.name
356
+ (direct if ours and alias.name in wanted else elsewhere).add(bound)
357
+ else:
358
+ for alias in node.names:
359
+ bound = alias.asname or alias.name.split(".")[0]
360
+ (modules if alias.name.split(".")[0] == package else elsewhere).add(bound)
361
+ taken = survey.rebound | elsewhere
362
+ return frozenset(direct - taken), frozenset(modules - taken)
363
+
364
+
365
+ def _names_a_call(node: ast.expr, names: tuple[frozenset[str], frozenset[str]], wanted: frozenset[str]) -> bool:
366
+ """Whether this call reaches one of *wanted* through the imports *names* resolved."""
367
+ if not isinstance(node, ast.Call):
368
+ return False
369
+ direct, modules = names
370
+ func = node.func
371
+ if isinstance(func, ast.Attribute):
372
+ return func.attr in wanted and isinstance(func.value, ast.Name) and func.value.id in modules
373
+ return isinstance(func, ast.Name) and func.id in direct
374
+
375
+
376
+ def _demands_a_raise(node: ast.With | ast.AsyncWith, names: tuple[frozenset[str], frozenset[str]]) -> bool:
377
+ """Whether this block fails when its body reaches the end quietly, and nothing else can end it.
378
+
379
+ One item only. A sibling manager gets its `__exit__` run after the body, so
380
+ ``with pytest.raises(TypeError), Boom():`` is satisfied by `Boom` and the body never had to raise.
381
+ """
382
+ return len(node.items) == 1 and _names_a_call(node.items[0].context_expr, names, _DEMANDS_A_RAISE)
383
+
384
+
385
+ def _only_statement_of_a_raising_block(survey: _Survey) -> frozenset[int]:
386
+ """The statements a `pytest.raises` block holds alone, by object identity.
387
+
388
+ The exemption is this narrow because every wider one measured unsound. A body of one statement
389
+ leaves the argument with nothing to hide behind: that statement runs, and either it raises, which
390
+ is what the test asserts, or the block itself raises `DID NOT RAISE`.
391
+
392
+ What the block raises is where the promise stops. An enclosing `try` that catches it, or a
393
+ `finally` that returns, leaves the test green again, and no check reading one statement can see
394
+ that. The guarantee is about the immediate block, not about the test around it.
395
+
396
+ Each condition is here for a shape that passed silently without it. A second statement can supply
397
+ the exception instead (`assert_that(x)` then `raise TypeError`), or make the chain unreachable
398
+ (`raise TypeError` then the chain). A sibling `with` item can raise from its own `__exit__`.
399
+
400
+ Keyed on `id()` rather than on a line, because two statements share a line when they are written
401
+ `assert_that(x); raise TypeError`, and exempting the line would exempt the chain as well.
402
+ """
403
+ names = _imported_as(survey, "pytest", _DEMANDS_A_RAISE)
404
+ return frozenset(
405
+ id(node.body[0]) for node in survey.blocks if len(node.body) == 1 and _demands_a_raise(node, names)
406
+ )
312
407
 
313
408
 
314
409
  def _marked_lines(source: str) -> frozenset[int]:
@@ -321,7 +416,13 @@ def _marked_lines(source: str) -> frozenset[int]:
321
416
 
322
417
  A file that tokenises differently from how it parsed yields nothing rather than raising: the parse
323
418
  already succeeded, so the check goes on without the escape hatch instead of taking the run down.
419
+
420
+ The text search in front is only to decide whether to tokenise at all. A file without the marker's
421
+ words anywhere cannot have it in a comment either, and two of this repository's 97 test modules
422
+ carry one, so tokenising all 97 was 278 ms spent to read two.
324
423
  """
424
+ if ALLOW_MARKER not in source:
425
+ return frozenset()
325
426
  try:
326
427
  tokens = tokenize.generate_tokens(io.StringIO(source).readline)
327
428
  return frozenset(one.start[0] for one in tokens if one.type == tokenize.COMMENT and ALLOW_MARKER in one.string)
@@ -353,13 +454,15 @@ def findings(source: str, path: str, extra_entries: frozenset[str] = frozenset()
353
454
  SyntaxError: if *source* does not parse; the caller decides whether that is its problem.
354
455
  """
355
456
  tree = ast.parse(source)
356
- bindings = _bindings(tree, extra_entries)
457
+ survey = _survey(tree)
458
+ bindings = _bindings(survey, extra_entries)
357
459
  if not bindings:
358
460
  return []
359
461
  marked = _marked_lines(source)
462
+ alone = _only_statement_of_a_raising_block(survey)
360
463
  found: list[Finding] = []
361
- for statement, scope in _statements(tree):
362
- if _silenced(statement, marked):
464
+ for statement, scope in survey.statements:
465
+ if _silenced(statement, marked) or id(statement) in alone:
363
466
  continue
364
467
  if isinstance(statement, ast.Assert):
365
468
  if _reads_a_truthy_chain(statement.test, bindings):